From c4ac24c6209f39fa36ba2a5195269ce52b15617c Mon Sep 17 00:00:00 2001 From: Jacob Bosco Date: Tue, 25 Oct 2022 10:43:17 +0530 Subject: [PATCH] WTA #44: Updated CalculateCurrentStreakUC to handle some base cases and added tests. --- .../jacob/wakatimeapp/core/common/Utils.kt | 6 +- .../usecases/CalculateCurrentStreakUC.kt | 91 +++++++----- .../usecases/CalculateCurrentStreakUCRobot.kt | 75 +++++++--- .../usecases/CalculateCurrentStreakUCTest.kt | 136 +++++++++++++++--- 4 files changed, 235 insertions(+), 73 deletions(-) diff --git a/core/common/src/main/java/com/jacob/wakatimeapp/core/common/Utils.kt b/core/common/src/main/java/com/jacob/wakatimeapp/core/common/Utils.kt index f6e54b52..cd9c3c94 100644 --- a/core/common/src/main/java/com/jacob/wakatimeapp/core/common/Utils.kt +++ b/core/common/src/main/java/com/jacob/wakatimeapp/core/common/Utils.kt @@ -2,13 +2,13 @@ package com.jacob.wakatimeapp.core.common import java.time.format.TextStyle.SHORT import java.util.Locale -import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -val LocalDate.Companion.today - get() = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date +fun Instant.toDate(timeZone: TimeZone = TimeZone.currentSystemDefault()) = + toLocalDateTime(timeZone).date fun LocalDate.getDisplayNameForDay(): String = dayOfWeek.getDisplayName(SHORT, Locale.getDefault()) diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUC.kt b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUC.kt index 83d5302e..26e85f7b 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUC.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUC.kt @@ -2,54 +2,77 @@ package com.jacob.wakatimeapp.home.domain.usecases import arrow.core.Either import arrow.core.computations.either -import arrow.core.right -import com.jacob.wakatimeapp.core.common.today +import com.jacob.wakatimeapp.core.common.toDate import com.jacob.wakatimeapp.core.models.Error import com.jacob.wakatimeapp.core.models.Time import com.jacob.wakatimeapp.home.data.local.HomePageCache +import com.jacob.wakatimeapp.home.domain.InstantProvider +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats import com.jacob.wakatimeapp.home.domain.models.StreakRange import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first -import kotlinx.datetime.LocalDate +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.minus @Singleton class CalculateCurrentStreakUC @Inject constructor( - dispatcher: CoroutineContext = Dispatchers.IO, private val homePageCache: HomePageCache, + private val instantProvider: InstantProvider, ) { - suspend operator fun invoke(): Either { - val currentStreakFlow = homePageCache.getCurrentStreak().first() - val last7DaysStatsFlow = homePageCache.getLast7DaysStats() - - last7DaysStatsFlow.collect { last7DaysStatsEither -> - either { - val last7DaysStats = last7DaysStatsEither.bind() - val currentStreak = currentStreakFlow.bind() - - val todaysStats = last7DaysStats.weeklyTimeSpent[LocalDate.today] ?: Time.ZERO - - if (todaysStats == Time.ZERO) return@either - - val updatedStreakRange = if (currentStreak == StreakRange.ZERO) { - StreakRange( - start = LocalDate.today, - end = LocalDate.today, - ) - } else { - StreakRange( - start = currentStreak.start, - end = LocalDate.today, - ) - } - - homePageCache.updateCurrentStreak(updatedStreakRange) - } + suspend operator fun invoke(): Either = either { + val last7DaysStats = homePageCache.getLast7DaysStats().first().bind() + val currentStreak = homePageCache.getCurrentStreak().first().bind() + + val today = instantProvider.now().toDate() + val todaysStats = last7DaysStats.weeklyTimeSpent[today] ?: Time.ZERO + + val endOfCurrentStreakIsYesterday = currentStreak.end == today.minus(1, DateTimeUnit.DAY) + + when { + isCurrentStreakActive(endOfCurrentStreakIsYesterday, todaysStats) -> currentStreak + + isCurrentStreakOngoing( + endOfCurrentStreakIsYesterday, + todaysStats + ) -> currentStreak.copy(end = today) + + else -> getNewCurrentStreak(currentStreak, last7DaysStats) + } + } + + /** + * Streak is considered active if the last date in the streak is the previous day but + * there is no stats for the current day. + */ + private fun isCurrentStreakActive(endOfCurrentStreakIsYesterday: Boolean, todaysStats: Time) = + endOfCurrentStreakIsYesterday && todaysStats == Time.ZERO + + /** + * Streak is considered on-going if there is an active streak and there is stats for the current day. + */ + private fun isCurrentStreakOngoing(endOfCurrentStreakIsYesterday: Boolean, todaysStats: Time) = + endOfCurrentStreakIsYesterday && todaysStats != Time.ZERO + + private fun getNewCurrentStreak( + currentStreak: StreakRange, + last7DaysStats: Last7DaysStats, + ): StreakRange { + if (instantProvider.now().toDate().minus(currentStreak.end).days > 7) { } - return StreakRange.ZERO.right() + val statsForCurrentStreakRange = last7DaysStats.weeklyTimeSpent + .toSortedMap() + .entries + .reversed() + .takeWhile { it.value != Time.ZERO } + + if (statsForCurrentStreakRange.isEmpty()) return StreakRange.ZERO + + return StreakRange( + start = statsForCurrentStreakRange.last().key, + end = statsForCurrentStreakRange.first().key, + ) } } diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCRobot.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCRobot.kt index 82ca97c4..c466fd2d 100644 --- a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCRobot.kt +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCRobot.kt @@ -1,23 +1,27 @@ package com.jacob.wakatimeapp.home.domain.usecases import arrow.core.Either -import com.jacob.wakatimeapp.core.common.today import com.jacob.wakatimeapp.core.models.Error import com.jacob.wakatimeapp.core.models.Time import com.jacob.wakatimeapp.home.data.local.HomePageCache +import com.jacob.wakatimeapp.home.domain.InstantProvider import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats import com.jacob.wakatimeapp.home.domain.models.StreakRange +import io.kotest.assertions.asClue import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.datetime.LocalDate +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime -@OptIn(ExperimentalCoroutinesApi::class) internal class CalculateCurrentStreakUCRobot { private lateinit var useCase: CalculateCurrentStreakUC @@ -30,8 +34,12 @@ internal class CalculateCurrentStreakUCRobot { result = null useCase = CalculateCurrentStreakUC( - dispatcher = UnconfinedTestDispatcher(), homePageCache = mockCache, + instantProvider = object : InstantProvider { + override val timeZone = TimeZone.UTC + + override fun now() = currentDayInstant + } ) } @@ -39,8 +47,10 @@ internal class CalculateCurrentStreakUCRobot { result = useCase() } - fun resultsShouldBe(expected: Error?) = apply { - result shouldBe expected + fun resultsShouldBe(expected: Either) = apply { + result.asClue { + it shouldBe expected + } } fun mockGetCurrentStreak(data: Either) = apply { @@ -51,18 +61,51 @@ internal class CalculateCurrentStreakUCRobot { coEvery { mockCache.getLast7DaysStats() } returns flowOf(data) } - fun verifyUpdateCurrentStreakCalled(data: StreakRange, count: Int = 1) = apply { - coVerify(exactly = count) { mockCache.updateCurrentStreak(data) } - } - - companion object { + internal companion object { val streakRange = StreakRange.ZERO - val today = LocalDate.today + + /** + * Start of a random day + * + * Value: + * - date: 11/10/2022 (dd/mm/yyyy) + * - time: 00:00:00 (hh:mm::ss) + */ + val startOfDay = Instant.parse("2022-10-11T00:00:00Z") + + val currentDayInstant = startOfDay + 1.hours + 30.minutes + + /** + * Takes [currentDayInstant] and subtracts 1 day from it + */ + val previousDayInstant = currentDayInstant.minus(1.days) + + val currentDay = currentDayInstant.toLocalDateTime(TimeZone.UTC).date + + val noWeeklyStats = mapOf( + currentDay to Time.ZERO, + currentDay.minus(1, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(2, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(3, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(4, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(5, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(6, DateTimeUnit.DAY) to Time.ZERO, + ) + + val continuousWeeklyStats = mutableMapOf( + currentDay to Time.fromDecimal(1f), + currentDay.minus(1, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(2, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(3, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(4, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(5, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(6, DateTimeUnit.DAY) to Time.fromDecimal(1f), + ) val last7DaysStats = Last7DaysStats( timeSpentToday = Time.ZERO, projectsWorkedOn = listOf(), - weeklyTimeSpent = mutableMapOf(), + weeklyTimeSpent = noWeeklyStats, mostUsedLanguage = "", mostUsedEditor = "", mostUsedOs = "" diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCTest.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCTest.kt index fb427c73..bf04971e 100644 --- a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCTest.kt +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCTest.kt @@ -3,12 +3,13 @@ package com.jacob.wakatimeapp.home.domain.usecases import arrow.core.right import com.jacob.wakatimeapp.core.models.Time import com.jacob.wakatimeapp.home.domain.models.StreakRange +import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.continuousWeeklyStats +import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.currentDay import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.last7DaysStats -import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.today +import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.noWeeklyStats import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import kotlinx.datetime.DatePeriod -import kotlinx.datetime.LocalDate +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.minus import org.junit.jupiter.api.Test @@ -19,32 +20,127 @@ internal class CalculateCurrentStreakUCTest { @Test internal fun `when there is no value in the cache, then set current streak value to 0`() = runTest { - val map = mutableMapOf() - for (i in 0 until 7) { - map[today.minus(DatePeriod(years = 0, months = 0, days = i))] = Time.ZERO - } + robot.buildUseCase() + .mockGetCurrentStreak(StreakRange.ZERO.right()) + .mockGetLast7DaysStats(last7DaysStats.right()) + .callUseCase() + .resultsShouldBe(StreakRange.ZERO.right()) + } - val updatedLast7DaysStats = last7DaysStats.copy(weeklyTimeSpent = map) + @Test + internal fun `when there is a streak in the cache that ends the previous day and there is no coding stats for that day, then do not change to value`() = + runTest { + val streakRange = StreakRange( + start = currentDay.minus(3, DateTimeUnit.DAY), + end = currentDay.minus(1, DateTimeUnit.DAY) + ) + robot.buildUseCase() + .mockGetCurrentStreak(streakRange.right()) + .mockGetLast7DaysStats(last7DaysStats.copy(weeklyTimeSpent = noWeeklyStats).right()) + .callUseCase() + .resultsShouldBe(streakRange.right()) + } + @Test + internal fun `when there is a streak in the cache that ends the previous day and there is coding stats for that day, then streak should increase by 1 day`() = + runTest { + val streakRange = StreakRange( + start = currentDay.minus(3, DateTimeUnit.DAY), + end = currentDay.minus(1, DateTimeUnit.DAY) + ) robot.buildUseCase() - .mockGetCurrentStreak(StreakRange.ZERO.right()) - .mockGetLast7DaysStats(updatedLast7DaysStats.right()) + .mockGetCurrentStreak(streakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = continuousWeeklyStats).right() + ) .callUseCase() - .resultsShouldBe(null) + .resultsShouldBe(streakRange.copy(end = currentDay).right()) } @Test - internal fun `when there is a value in the cache, then increase the value by 1`() { - TODO("Not yet implemented") - } + internal fun `when there is a streak in the cache but it ended within the last 7 days and there is no coding stats since then, then set current streak to zero`() = + runTest { + val streakRange = StreakRange( + start = currentDay.minus(8, DateTimeUnit.DAY), + end = currentDay.minus(4, DateTimeUnit.DAY) + ) + robot.buildUseCase() + .mockGetCurrentStreak(streakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = noWeeklyStats).right() + ) + .callUseCase() + .resultsShouldBe(StreakRange.ZERO.right()) + } @Test - internal fun `when last 7 days stats cache sends multiple values, then streak should only increase by 1`() { - TODO("Not yet implemented") - } + internal fun `when there is a streak in the cache but its end is within the last 7 days and there is coding stats since then, then re-calculate the streak`() = + runTest { + val initialStreakRange = StreakRange( + start = currentDay.minus(8, DateTimeUnit.DAY), + end = currentDay.minus(4, DateTimeUnit.DAY) + ) + + val result = StreakRange( + start = currentDay.minus(2, DateTimeUnit.DAY), + end = currentDay, + ) + + val updatedWeeklyStats = continuousWeeklyStats.toMutableMap().also { + it[currentDay.minus(3, DateTimeUnit.DAY)] = Time.ZERO + it[currentDay.minus(4, DateTimeUnit.DAY)] = Time.ZERO + } + + robot.buildUseCase() + .mockGetCurrentStreak(initialStreakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = updatedWeeklyStats).right() + ) + .callUseCase() + .resultsShouldBe(result.right()) + } @Test - internal fun `when there is non 0 value in cache and todays stats arrives later, then streak should increase by 1`() { - TODO("Not yet implemented") - } + internal fun `when the current streak has not been updated for a few days and there is more coding activity since then, then calculating should update the original streak`() = + runTest { + val initialStreakRange = StreakRange( + start = currentDay.minus(10, DateTimeUnit.DAY), + end = currentDay.minus(4, DateTimeUnit.DAY) + ) + + val result = StreakRange( + start = currentDay.minus(8, DateTimeUnit.DAY), + end = currentDay, + ) + + robot.buildUseCase() + .mockGetCurrentStreak(initialStreakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = continuousWeeklyStats).right() + ) + .callUseCase() + .resultsShouldBe(result.right()) + } + + @Test + internal fun `when the current streak has not been updated for over a week and there is more coding activity since then, then calculating should update the original streak`() = + runTest { + val initialStreakRange = StreakRange( + start = currentDay.minus(15, DateTimeUnit.DAY), + end = currentDay.minus(10, DateTimeUnit.DAY) + ) + + val result = StreakRange( + start = currentDay.minus(15, DateTimeUnit.DAY), + end = currentDay, + ) + + robot.buildUseCase() + .mockGetCurrentStreak(initialStreakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = continuousWeeklyStats).right() + ) + .callUseCase() + .resultsShouldBe(result.right()) + } }