diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8ffcf36..aa8fcac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,8 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.mockk) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.test.core) diff --git a/app/src/main/java/com/jdluu/flexinsight/data/ai/HevyAiDataAccessor.kt b/app/src/main/java/com/jdluu/flexinsight/data/ai/HevyAiDataAccessor.kt index 36afc7f..980f089 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/ai/HevyAiDataAccessor.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/ai/HevyAiDataAccessor.kt @@ -11,6 +11,7 @@ import com.jdluu.flexinsight.data.repository.FlexRepository import com.jdluu.flexinsight.data.repository.RoutineRepository import com.jdluu.flexinsight.data.repository.StatsRepository import com.jdluu.flexinsight.data.repository.WorkoutRepository +import com.jdluu.flexinsight.domain.ai.AiContextProvider import kotlinx.coroutines.flow.first import java.text.SimpleDateFormat import java.util.Date @@ -35,7 +36,7 @@ class HevyAiDataAccessor @Inject constructor( private val userPreferencesManager: UserPreferencesManager, private val apiKeyManager: ApiKeyManager, private val healthConnectRepository: HealthConnectRepository -) { +) : AiContextProvider { data class ContextSnapshot( val text: String, @@ -46,7 +47,7 @@ class HevyAiDataAccessor @Inject constructor( val usesLiveExerciseHistory: Boolean = false ) - suspend fun buildContext(userQuery: String? = null): ContextSnapshot { + override suspend fun buildContext(userQuery: String?): ContextSnapshot { val hasApiKey = apiKeyManager.hasApiKey() val workoutCount = try { flexRepository.getWorkoutCount().first() diff --git a/app/src/main/java/com/jdluu/flexinsight/di/AiModule.kt b/app/src/main/java/com/jdluu/flexinsight/di/AiModule.kt index 6e1a650..e3f1a4a 100644 --- a/app/src/main/java/com/jdluu/flexinsight/di/AiModule.kt +++ b/app/src/main/java/com/jdluu/flexinsight/di/AiModule.kt @@ -2,6 +2,8 @@ package com.jdluu.flexinsight.di import com.jdluu.flexinsight.data.ai.FlexAIClient import com.jdluu.flexinsight.data.ai.GeminiNanoClient +import com.jdluu.flexinsight.data.ai.HevyAiDataAccessor +import com.jdluu.flexinsight.domain.ai.AiContextProvider import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +19,10 @@ abstract class AiModule { abstract fun bindFlexAIClient( geminiNanoClient: GeminiNanoClient ): FlexAIClient + + @Binds + @Singleton + abstract fun bindAiContextProvider( + hevyAiDataAccessor: HevyAiDataAccessor + ): AiContextProvider } diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/ai/AiContextProvider.kt b/app/src/main/java/com/jdluu/flexinsight/domain/ai/AiContextProvider.kt new file mode 100644 index 0000000..195d128 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/domain/ai/AiContextProvider.kt @@ -0,0 +1,18 @@ +package com.jdluu.flexinsight.domain.ai + +import com.jdluu.flexinsight.data.ai.HevyAiDataAccessor + +/** + * Source of AI prompt context built from user workout data. + * + * Extracted from [HevyAiDataAccessor] so domain code depends on an abstraction + * rather than the Hevy-specific implementation. + */ +interface AiContextProvider { + /** + * Builds the system prompt context snapshot. + * + * @param userQuery When set, triggers live Hevy API exercise-history lookups for relevant lifts. + */ + suspend fun buildContext(userQuery: String? = null): HevyAiDataAccessor.ContextSnapshot +} diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/usecase/BuildAiContextUseCase.kt b/app/src/main/java/com/jdluu/flexinsight/domain/usecase/BuildAiContextUseCase.kt index ad224af..f4c2676 100644 --- a/app/src/main/java/com/jdluu/flexinsight/domain/usecase/BuildAiContextUseCase.kt +++ b/app/src/main/java/com/jdluu/flexinsight/domain/usecase/BuildAiContextUseCase.kt @@ -1,6 +1,7 @@ package com.jdluu.flexinsight.domain.usecase import com.jdluu.flexinsight.data.ai.HevyAiDataAccessor +import com.jdluu.flexinsight.domain.ai.AiContextProvider import javax.inject.Inject /** @@ -9,9 +10,9 @@ import javax.inject.Inject * @param userQuery When set, triggers live Hevy API exercise-history lookups for relevant lifts. */ class BuildAiContextUseCase @Inject constructor( - private val hevyAiDataAccessor: HevyAiDataAccessor + private val aiContextProvider: AiContextProvider ) { suspend operator fun invoke(userQuery: String? = null): HevyAiDataAccessor.ContextSnapshot { - return hevyAiDataAccessor.buildContext(userQuery) + return aiContextProvider.buildContext(userQuery) } } diff --git a/app/src/test/java/com/jdluu/flexinsight/data/repository/StatsCalculatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/data/repository/StatsCalculatorTest.kt deleted file mode 100644 index 6280140..0000000 --- a/app/src/test/java/com/jdluu/flexinsight/data/repository/StatsCalculatorTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.jdluu.flexinsight.domain.util - -import com.jdluu.flexinsight.data.model.Exercise -import com.jdluu.flexinsight.data.model.Set -import com.jdluu.flexinsight.data.model.Workout -import org.junit.Assert.assertEquals -import org.junit.Test -import java.time.Instant -import java.time.ZoneId - -class StatsCalculatorTest { - - private fun createWorkout(id: String, startTime: Long, endTime: Long?): Workout { - return Workout( - id = id, - name = "Workout $id", - startTime = startTime, - endTime = endTime, - notes = null, - routineId = null - ) - } - - private fun createExercise(id: String, workoutId: String): Exercise { - return Exercise( - id = id, - workoutId = workoutId, - exerciseTemplateId = null, - name = "Exercise $id", - notes = null, - restDuration = null - ) - } - - private fun createSet(id: String, exerciseId: String, weight: Double?, reps: Int?): Set { - return Set( - id = id, - exerciseId = exerciseId, - number = 1, - weight = weight, - reps = reps, - rpe = null, - distance = null, - duration = null, - restDuration = null, - notes = null, - isPersonalRecord = false - ) - } - - @Test - fun calculateTotalVolume_correctlySumsVolume() { - val workout = createWorkout("w1", 0, 0) - val exercise = createExercise("e1", "w1") - val set1 = createSet("s1", "e1", 100.0, 10) // 1000 - val set2 = createSet("s2", "e1", 50.0, 10) // 500 - - val volume = StatsCalculator.calculateTotalVolume( - listOf(workout), - listOf(exercise), - listOf(set1, set2) - ) - - assertEquals(1500.0, volume, 0.01) - } - - @Test - fun calculateStreak_detectsSequence() { - val now = System.currentTimeMillis() - val dayMillis = 24 * 60 * 60 * 1000L - - // Workout today, yesterday, 2 days ago - val w1 = createWorkout("w1", now, now + 1000) - val w2 = createWorkout("w2", now - dayMillis, now - dayMillis + 1000) - val w3 = createWorkout("w3", now - 2 * dayMillis, now - 2 * dayMillis + 1000) - - val streak = StatsCalculator.calculateStreak(listOf(w1, w2, w3)) - assertEquals(3, streak) - } - - @Test - fun calculateStreak_breaksOnMissedDay() { - val now = System.currentTimeMillis() - val dayMillis = 24 * 60 * 60 * 1000L - - // Workout today, SKIP yesterday, 2 days ago - val w1 = createWorkout("w1", now, now + 1000) - val w3 = createWorkout("w3", now - 2 * dayMillis, now - 2 * dayMillis + 1000) - - val streak = StatsCalculator.calculateStreak(listOf(w1, w3)) - assertEquals(1, streak) - } - - @Test - fun calculateLongestStreak_findsLongest() { - val now = System.currentTimeMillis() - val dayMillis = 24 * 60 * 60 * 1000L - - // 3 days streak ... gap ... 5 days streak - val gap = 10 * dayMillis - - val sequence1 = (0..2).map { i -> - createWorkout("s1_$i", now - i * dayMillis, now - i * dayMillis + 1000) - } - - val sequence2 = (0..4).map { i -> - createWorkout("s2_$i", now - gap - i * dayMillis, now - gap - i * dayMillis + 1000) - } - - val allWorkouts = sequence1 + sequence2 - val longest = StatsCalculator.calculateLongestStreak(allWorkouts) - - assertEquals(5, longest) - } - - @Test - fun getStartOfDay_correctTimestamp() { - val now = System.currentTimeMillis() - val startOfDay = StatsCalculator.getStartOfDay(now) - - val localDate = Instant.ofEpochMilli(now).atZone(ZoneId.systemDefault()).toLocalDate() - val expected = localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() - - assertEquals(expected, startOfDay) - } -} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/BuildAiContextUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/BuildAiContextUseCaseTest.kt new file mode 100644 index 0000000..ffe85fe --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/BuildAiContextUseCaseTest.kt @@ -0,0 +1,53 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.data.ai.HevyAiDataAccessor +import com.jdluu.flexinsight.domain.ai.AiContextProvider +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class BuildAiContextUseCaseTest { + + private fun snapshot(text: String, usesLive: Boolean = false) = HevyAiDataAccessor.ContextSnapshot( + text = text, + hasWorkoutData = true, + hasApiKey = true, + workoutCount = 42, + usesLiveExerciseHistory = usesLive + ) + + private class FakeContextProvider : AiContextProvider { + var lastQuery: String? = null + lateinit var response: HevyAiDataAccessor.ContextSnapshot + + override suspend fun buildContext(userQuery: String?): HevyAiDataAccessor.ContextSnapshot { + lastQuery = userQuery + return response + } + } + + @Test + fun `delegates to provider with null query by default`() = runTest { + val provider = FakeContextProvider() + val expected = snapshot("context text") + provider.response = expected + + val result = BuildAiContextUseCase(provider)() + + assertEquals(expected, result) + assertEquals(null, provider.lastQuery) + } + + @Test + fun `forwards non-null user query to provider`() = runTest { + val provider = FakeContextProvider() + val expected = snapshot("live history context", usesLive = true) + provider.response = expected + + val result = BuildAiContextUseCase(provider)(userQuery = "how is my bench?") + + assertSame(expected, result) + assertEquals("how is my bench?", provider.lastQuery) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/CalculateTrainingLoadUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/CalculateTrainingLoadUseCaseTest.kt new file mode 100644 index 0000000..d17b2d5 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/CalculateTrainingLoadUseCaseTest.kt @@ -0,0 +1,301 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.data.health.HealthConnectRepository +import com.jdluu.flexinsight.data.health.HealthConnectSnapshot +import com.jdluu.flexinsight.data.model.WeeklyGoalProgress +import com.jdluu.flexinsight.fakes.FakeFlexRepository +import com.jdluu.flexinsight.fakes.TestDefaults +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class CalculateTrainingLoadUseCaseTest { + + private lateinit var flexRepo: FakeFlexRepository + private val healthRepo = mockk() + + private val originalLocale = Locale.getDefault() + + @Before + fun setUp() { + flexRepo = FakeFlexRepository() + Locale.setDefault(Locale.US) + } + + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + + private fun snapshot( + sleepHours: Double? = null, + restingHr: Long? = null, + steps: Long? = null, + cardioSessions: Int = 0 + ): HealthConnectSnapshot { + val snap = HealthConnectSnapshot( + isAvailable = true, + isPermissionGranted = true, + sleepHoursLastNight = sleepHours, + restingHeartRateBpm = restingHr, + stepsToday = steps, + cardioSessionsThisWeek = cardioSessions + ) + coEvery { healthRepo.readSnapshot() } returns snap + return snap + } + + // ---- Hevy volume score ---- + + @Test + fun `volume score derived from weekly goal completion`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(completed = 4, target = 4, status = "On Track") + snapshot() + + val result = CalculateTrainingLoadUseCase(flexRepo, healthRepo)() + + assertEquals(100, result.hevyVolumeScore) + } + + @Test + fun `volume score truncates fractional percentage`() = runTest { + // 2/3 of goal = 66.67%, integer division via toInt() truncates to 66 + flexRepo.goalToReturn = WeeklyGoalProgress(completed = 2, target = 3, status = "Behind") + snapshot() + + assertEquals(66, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().hevyVolumeScore) + } + + @Test + fun `volume score coerced to 100 when goal exceeded`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(completed = 10, target = 5, status = "On Track") + snapshot() + + assertEquals(100, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().hevyVolumeScore) + } + + @Test + fun `volume score coerced to 0 when nothing completed`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(completed = 0, target = 5, status = "Behind") + snapshot() + + assertEquals(0, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().hevyVolumeScore) + } + + @Test + fun `zero target goal skips goal branch and uses workout count fallback`() = runTest { + // NOTE: possibly unintended - a configured goal with target == 0 silently falls through + flexRepo.goalToReturn = WeeklyGoalProgress(completed = 0, target = 0, status = "Behind") + flexRepo.statsToReturn = TestDefaults.emptyStats.copy(totalWorkouts = 3) + snapshot() + + assertEquals(65, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().hevyVolumeScore) + } + + @Test + fun `goal failure with existing workouts yields default 65`() = runTest { + flexRepo.goalError = IllegalStateException("db closed") + flexRepo.statsToReturn = TestDefaults.emptyStats.copy(totalWorkouts = 1) + snapshot() + + assertEquals(65, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().hevyVolumeScore) + } + + @Test + fun `stats and goal failures yield floor score of 30`() = runTest { + flexRepo.goalError = IllegalStateException("db closed") + flexRepo.statsError = IllegalStateException("db closed") + snapshot() + + assertEquals(30, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().hevyVolumeScore) + } + + // ---- Cardio score ---- + + @Test + fun `cardio score is 15 points per session`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(0, 5, "Behind") + snapshot(cardioSessions = 4) + + assertEquals(60, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().cardioScore) + } + + @Test + fun `cardio score capped at 100`() = runTest { + // NOTE: possibly unintended - 7 sessions would naively be 105; capped at 100 + snapshot(cardioSessions = 7) + + assertEquals(100, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().cardioScore) + } + + @Test + fun `no cardio sessions scores zero`() = runTest { + snapshot(cardioSessions = 0) + + assertEquals(0, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().cardioScore) + } + + // ---- Sleep score bands ---- + + @Test + fun `missing sleep data scores neutral 50`() = runTest { + snapshot(sleepHours = null) + + assertEquals(50, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().sleepScore) + } + + @Test + fun `sleep of exactly 7 hours scores 90`() = runTest { + snapshot(sleepHours = 7.0) + + assertEquals(90, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().sleepScore) + } + + @Test + fun `sleep between 6 and 7 hours scores 70`() = runTest { + snapshot(sleepHours = 6.5) + + assertEquals(70, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().sleepScore) + } + + @Test + fun `sleep of exactly 6 hours scores 70 at band lower bound`() = runTest { + snapshot(sleepHours = 6.0) + + assertEquals(70, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().sleepScore) + } + + @Test + fun `sleep between 5 and 6 hours scores 50`() = runTest { + snapshot(sleepHours = 5.5) + + assertEquals(50, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().sleepScore) + } + + @Test + fun `sleep below 5 hours scores 25`() = runTest { + snapshot(sleepHours = 4.99) + + assertEquals(25, CalculateTrainingLoadUseCase(flexRepo, healthRepo)().sleepScore) + } + + // ---- Overall score and label ---- + + @Test + fun `high load label at 80 or above`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(4, 4, "On Track") + snapshot(sleepHours = 8.0, cardioSessions = 4) // 100*0.5 + 60*0.2 + 90*0.3 = 89 + + val result = CalculateTrainingLoadUseCase(flexRepo, healthRepo)() + + assertEquals(89, result.overall) + assertEquals("High load", result.label) + } + + @Test + fun `moderate load label between 55 and 79`() = runTest { + flexRepo.goalError = IllegalStateException("no goal") + flexRepo.statsToReturn = TestDefaults.emptyStats.copy(totalWorkouts = 3) // volume 65 + snapshot(sleepHours = 6.5, cardioSessions = 1) // 65*0.5 + 15*0.2 + 70*0.3 = 56.5 -> 56 + + val result = CalculateTrainingLoadUseCase(flexRepo, healthRepo)() + + assertEquals(56, result.overall) + assertEquals("Moderate load", result.label) + } + + @Test + fun `recovery focus label below 55`() = runTest { + flexRepo.goalError = IllegalStateException("no goal") + flexRepo.statsError = IllegalStateException("no stats") // volume 30 + snapshot() // cardio 0, sleep null -> 50 + // 30*0.5 + 0*0.2 + 50*0.3 = 30 + + val result = CalculateTrainingLoadUseCase(flexRepo, healthRepo)() + + assertEquals(30, result.overall) + assertEquals("Recovery focus", result.label) + } + + @Test + fun `overall stays recovery focused just under moderate threshold`() = runTest { + flexRepo.goalError = IllegalStateException("no goal") + flexRepo.statsError = IllegalStateException("no stats") // volume 30 + snapshot(cardioSessions = 7) // cardio capped 100 -> 30*0.5 + 100*0.2 + 50*0.3 = 50 + + val result = CalculateTrainingLoadUseCase(flexRepo, healthRepo)() + + assertEquals(50, result.overall) + assertEquals("Recovery focus", result.label) + } + + // ---- Detail string ---- + + @Test + fun `detail omits health parts when snapshot has no data`() = runTest { + flexRepo.goalError = IllegalStateException("no goal") + flexRepo.statsToReturn = TestDefaults.emptyStats.copy(totalWorkouts = 2) + snapshot() // all fields null/zero -> hasData false + + assertEquals( + "Hevy volume 65/100", + CalculateTrainingLoadUseCase(flexRepo, healthRepo)().detail + ) + } + + @Test + fun `detail includes sleep steps and cardio when present`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(3, 5, "Behind") // volume 60 + snapshot(sleepHours = 7.5, steps = 5000L, cardioSessions = 2) + + assertEquals( + "Hevy volume 60/100, sleep 7.5h, steps 5000, 2 cardio sessions", + CalculateTrainingLoadUseCase(flexRepo, healthRepo)().detail + ) + } + + @Test + fun `detail ignores resting heart rate presence`() = runTest { + // hasData becomes true via restingHr alone, but detail never mentions it + // NOTE: possibly unintended - resting HR counts as "has data" yet never appears in detail + flexRepo.goalError = IllegalStateException("no goal") + flexRepo.statsToReturn = TestDefaults.emptyStats.copy(totalWorkouts = 1) + snapshot(restingHr = 58L) + + assertEquals( + "Hevy volume 65/100", + CalculateTrainingLoadUseCase(flexRepo, healthRepo)().detail + ) + } + + @Test + fun `detail omits cardio segment when zero sessions`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(1, 5, "Behind") // volume 20 + snapshot(sleepHours = 8.0, steps = 12000L, cardioSessions = 0) + + assertEquals( + "Hevy volume 20/100, sleep 8.0h, steps 12000", + CalculateTrainingLoadUseCase(flexRepo, healthRepo)().detail + ) + } + + // ---- Error propagation asymmetry ---- + + @Test + fun `health snapshot failure is not caught and propagates`() { + // Unlike the FlexRepository calls, readSnapshot() is not wrapped in runCatching + coEvery { healthRepo.readSnapshot() } throws RuntimeException("provider crashed") + + assertThrows(RuntimeException::class.java) { + kotlinx.coroutines.runBlocking { + CalculateTrainingLoadUseCase(flexRepo, healthRepo)() + } + } + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/CompareRoutineSessionsUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/CompareRoutineSessionsUseCaseTest.kt new file mode 100644 index 0000000..deb0e4d --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/CompareRoutineSessionsUseCaseTest.kt @@ -0,0 +1,325 @@ +package com.jdluu.flexinsight.domain.usecase + +import app.cash.turbine.test +import com.jdluu.flexinsight.fakes.FakeWorkoutRepository +import com.jdluu.flexinsight.fakes.TestDefaults.exercise +import com.jdluu.flexinsight.fakes.TestDefaults.set +import com.jdluu.flexinsight.fakes.TestDefaults.workout +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CompareRoutineSessionsUseCaseTest { + + private val repo = FakeWorkoutRepository() + private val useCase = CompareRoutineSessionsUseCase(repo) + + private companion object { + const val ROUTINE = "r1" + const val ROUTINE_NAME = "Push Day" + const val T0 = 1_700_000_000_000L + const val T1 = 1_700_086_400_000L + const val T2 = 1_700_172_800_000L + const val T3 = 1_700_259_200_000L + } + + private fun seedTwoSessionScenario( + recentName: String = "Bench Press", + previousName: String = "Bench Press", + recentSets: List, + previousSets: List + ) { + repo.workoutsFlow.value = listOf(workout("rw", startTime = T2, routineId = ROUTINE)) + repo.workoutsFlow.value += workout("pw", startTime = T1, routineId = ROUTINE) + repo.exercisesByWorkout["rw"] = listOf(exercise("re", "rw", recentName)) + repo.exercisesByWorkout["pw"] = listOf(exercise("pe", "pw", previousName)) + repo.setsByExercise["re"] = recentSets + repo.setsByExercise["pe"] = previousSets + } + + // ---- Guard clauses ---- + + @Test + fun `null routine id returns null without comparing`() = runTest { + assertNull(useCase(routineId = null, routineName = ROUTINE_NAME)) + } + + @Test + fun `single session for routine returns null`() = runTest { + repo.workoutsFlow.value = listOf(workout("w1", startTime = T1, routineId = ROUTINE)) + + assertNull(useCase(ROUTINE, ROUTINE_NAME)) + } + + @Test + fun `no sessions at all returns null`() = runTest { + assertNull(useCase(ROUTINE, ROUTINE_NAME)) + } + + @Test + fun `deleted workouts are excluded from pairing`() = runTest { + repo.workoutsFlow.value = listOf( + workout("deleted-recent", startTime = T3, routineId = ROUTINE, isDeleted = true), + workout("recent", startTime = T2, routineId = ROUTINE), + workout("previous", startTime = T1, routineId = ROUTINE) + ) + repo.exercisesByWorkout["recent"] = listOf(exercise("re", "recent", "Bench Press")) + repo.exercisesByWorkout["previous"] = listOf(exercise("pe", "previous", "Bench Press")) + repo.setsByExercise["re"] = listOf(set("s1", "re", 100.0, 10)) // 1000 + repo.setsByExercise["pe"] = listOf(set("s2", "pe", 100.0, 10)) // 1000 + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertTrue(result!!.improvements.isEmpty() && result.regressions.isEmpty()) + } + + // ---- Pairing ---- + + @Test + fun `compares the two most recent sessions only`() = runTest { + repo.workoutsFlow.value = listOf( + workout("old", startTime = T0, routineId = ROUTINE), + workout("previous", startTime = T1, routineId = ROUTINE), + workout("recent", startTime = T2, routineId = ROUTINE) + ) + repo.exercisesByWorkout["recent"] = listOf(exercise("re", "recent", "Bench Press")) + repo.exercisesByWorkout["previous"] = listOf(exercise("pe", "previous", "Bench Press")) + repo.setsByExercise["re"] = listOf(set("s1", "re", 120.0, 10)) // 1200 + repo.setsByExercise["pe"] = listOf(set("s2", "pe", 100.0, 10)) // 1000 + + // If "old" were used as baseline instead of "previous", the outcome would differ; + // with correct pairing this is a clean improvement against the immediate prior run. + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertEquals(listOf("Bench Press: volume up"), result!!.improvements) + } + + @Test + fun `workouts from other routines are ignored`() = runTest { + repo.workoutsFlow.value = listOf( + workout("other-routine", startTime = T2, routineId = "r2"), + workout("recent", startTime = T2 - 1000, routineId = ROUTINE), + workout("previous", startTime = T1, routineId = ROUTINE) + ) + repo.exercisesByWorkout["recent"] = listOf(exercise("re", "recent", "Bench Press")) + repo.exercisesByWorkout["previous"] = listOf(exercise("pe", "previous", "Bench Press")) + repo.setsByExercise["re"] = listOf(set("s1", "re", 100.0, 10)) + repo.setsByExercise["pe"] = listOf(set("s2", "pe", 100.0, 10)) + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertTrue(result != null && result.improvements.isEmpty() && result.regressions.isEmpty()) + } + + @Test + fun `exercise names match ignoring case`() = runTest { + seedTwoSessionScenario( + recentName = "INCLINE BENCH PRESS", + previousName = "Incline Bench Press", + recentSets = listOf(set("s1", "re", 80.0, 8)), // 640 + previousSets = listOf(set("s2", "pe", 60.0, 8)) // 480 + ) + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertEquals(listOf("INCLINE BENCH PRESS: volume up"), result!!.improvements) + } + + @Test + fun `recent exercise without previous counterpart is skipped`() = runTest { + seedTwoSessionScenario( + recentName = "Brand New Lift", + previousName = "Something Else", + recentSets = listOf(set("s1", "re", 500.0, 5)), + previousSets = listOf(set("s2", "pe", 100.0, 10)) + ) + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertTrue(result!!.improvements.isEmpty() && result.regressions.isEmpty()) + } + + // ---- Best-set thresholds ---- + + @Test + fun `more than two percent above baseline counts as improvement`() = runTest { + seedTwoSessionScenario( + recentSets = listOf(set("s1", "re", 102.1, 10)), // 1021 + previousSets = listOf(set("s2", "pe", 100.0, 10)) // 1000 + ) + + assertEquals(listOf("Bench Press: volume up"), useCase(ROUTINE, ROUTINE_NAME)!!.improvements) + } + + @Test + fun `exactly two percent above baseline is considered stable`() = runTest { + seedTwoSessionScenario( + recentSets = listOf(set("s1", "re", 102.0, 10)), // 1020 == 1000 * 1.02 + previousSets = listOf(set("s2", "pe", 100.0, 10)) // 1000 + ) + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertTrue(result!!.improvements.isEmpty()) + assertEquals("Performance stable between last two $ROUTINE_NAME sessions.", result.summary) + } + + @Test + fun `more than two percent below baseline counts as regression`() = runTest { + seedTwoSessionScenario( + recentSets = listOf(set("s1", "re", 97.9, 10)), // 979 + previousSets = listOf(set("s2", "pe", 100.0, 10)) // 1000 + ) + + assertEquals( + listOf("Bench Press: volume down"), + useCase(ROUTINE, ROUTINE_NAME)!!.regressions + ) + } + + @Test + fun `exactly two percent below baseline is considered stable`() = runTest { + seedTwoSessionScenario( + recentSets = listOf(set("s1", "re", 98.0, 10)), // 980 == 1000 * 0.98 + previousSets = listOf(set("s2", "pe", 100.0, 10)) // 1000 + ) + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertTrue(result!!.regressions.isEmpty()) + } + + @Test + fun `best set across multiple sets determines direction`() = runTest { + seedTwoSessionScenario( + recentSets = listOf( + set("s1", "re", 80.0, 10), // 800 + set("s2", "re", 91.0, 10) // 910 <- best + ), + previousSets = listOf( + set("s3", "pe", 90.0, 10), // 900 <- best + set("s4", "pe", 70.0, 12) // 840 + ) + ) + // 910 vs 900: within +/-2% -> stable + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertTrue(result!!.improvements.isEmpty() && result.regressions.isEmpty()) + } + + // ---- Null / zero data handling ---- + + @Test + fun `sets with null weight or reps contribute zero volume`() = runTest { + // NOTE: possibly unintended - bodyweight/duration-only sets score as 0 kg-volume, + // so an all-bodyweight session always reads as "volume down" against weighted work + seedTwoSessionScenario( + recentSets = listOf(set("s1", "re", null, 15)), + previousSets = listOf(set("s2", "pe", 50.0, 10)) + ) + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertEquals(listOf("Bench Press: volume down"), result!!.regressions) + } + + @Test + fun `sessions with no sets compare as zeros and are stable`() = runTest { + seedTwoSessionScenario(recentSets = emptyList(), previousSets = emptyList()) + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertTrue(result!!.improvements.isEmpty() && result.regressions.isEmpty()) + assertEquals("Performance stable between last two $ROUTINE_NAME sessions.", result.summary) + } + + // ---- Summary wording ---- + + @Test + fun `summary reports regressions when nothing improved`() = runTest { + seedTwoSessionScenario( + recentSets = listOf(set("s1", "re", 50.0, 10)), // 500 + previousSets = listOf(set("s2", "pe", 100.0, 10)) // 1000 + ) + + assertEquals( + "Latest $ROUTINE_NAME session shows regressions vs the prior run.", + useCase(ROUTINE, ROUTINE_NAME)!!.summary + ) + } + + @Test + fun `summary reports improvement when nothing regressed`() = runTest { + seedTwoSessionScenario( + recentSets = listOf(set("s1", "re", 110.0, 10)), // 1100 + previousSets = listOf(set("s2", "pe", 100.0, 10)) // 1000 + ) + + assertEquals( + "Latest $ROUTINE_NAME session improved across key lifts.", + useCase(ROUTINE, ROUTINE_NAME)!!.summary + ) + } + + @Test + fun `summary reports mixed performance when both directions occur`() = runTest { + repo.workoutsFlow.value = listOf( + workout("rw", startTime = T2, routineId = ROUTINE), + workout("pw", startTime = T1, routineId = ROUTINE) + ) + repo.exercisesByWorkout["rw"] = listOf( + exercise("up", "rw", "Overhead Press"), + exercise("down", "rw", "Squat") + ) + repo.exercisesByWorkout["pw"] = listOf( + exercise("up-prev", "pw", "Overhead Press"), + exercise("down-prev", "pw", "Squat") + ) + repo.setsByExercise["up"] = listOf(set("s1", "up", 60.0, 10)) // 600 + repo.setsByExercise["up-prev"] = listOf(set("s2", "up-prev", 50.0, 10)) // 500 -> up + repo.setsByExercise["down"] = listOf(set("s3", "down", 80.0, 10)) // 800 + repo.setsByExercise["down-prev"] = listOf(set("s4", "down-prev", 100.0, 10)) // 1000 -> down + + val result = useCase(ROUTINE, ROUTINE_NAME) + + assertEquals( + "Mixed performance on $ROUTINE_NAME \u2014 review flagged lifts.", + result!!.summary + ) + assertEquals(listOf("Overhead Press: volume up"), result.improvements) + assertEquals(listOf("Squat: volume down"), result.regressions) + } + + @Test + fun `comparison payload echoes routine name`() = runTest { + seedTwoSessionScenario( + recentSets = listOf(set("s1", "re", 100.0, 10)), + previousSets = listOf(set("s2", "pe", 100.0, 10)) + ) + + val result = useCase(ROUTINE, "Heavy Upper") + + assertEquals("Heavy Upper", result!!.routineName) + } + + // ---- Flow consumption ---- + + @Test + fun `use case consumes latest emission of repository workouts flow`() = runTest { + repo.workoutsFlow.test { + assertEquals(emptyList(), awaitItem()) + + repo.workoutsFlow.value = listOf( + workout("recent", startTime = T2, routineId = ROUTINE), + workout("previous", startTime = T1, routineId = ROUTINE) + ) + assertEquals(2, awaitItem().size) + } + // first() inside the use case must observe the emitted pair + val result = useCase(ROUTINE, ROUTINE_NAME) + assertTrue(result != null) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/DetectDeloadUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/DetectDeloadUseCaseTest.kt new file mode 100644 index 0000000..7fd4d3a --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/DetectDeloadUseCaseTest.kt @@ -0,0 +1,172 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.data.health.HealthConnectRepository +import com.jdluu.flexinsight.data.health.HealthConnectSnapshot +import com.jdluu.flexinsight.data.model.DayInfo +import com.jdluu.flexinsight.data.model.VolumeTrend +import com.jdluu.flexinsight.fakes.FakeFlexRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class DetectDeloadUseCaseTest { + + private lateinit var flexRepo: FakeFlexRepository + private val healthRepo = mockk() + private val originalLocale = Locale.getDefault() + + @Before + fun setUp() { + flexRepo = FakeFlexRepository() + Locale.setDefault(Locale.US) + } + + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + + private fun health(sleepHours: Double? = null, restingHr: Long? = null) { + coEvery { healthRepo.readSnapshot() } returns HealthConnectSnapshot( + isAvailable = true, + isPermissionGranted = true, + sleepHoursLastNight = sleepHours, + restingHeartRateBpm = restingHr + ) + } + + private fun daysWithWorkouts(count: Int): List = + (1..count).map { DayInfo("Day$it", it, it.toLong(), hasWorkout = true, isCompleted = true, workoutCount = 1) } + + private fun fullyLoadedSetup(trendPct: Double = 20.0, sessions: Int = 10) { + flexRepo.trendToReturn = VolumeTrend(1200.0, 1000.0, trendPct) + flexRepo.consistencyToReturn = daysWithWorkouts(sessions) + health(sleepHours = 5.0) + } + + @Test + fun `flags deload when all signals align`() = runTest { + fullyLoadedSetup(trendPct = 20.0) + + val alert = DetectDeloadUseCase(flexRepo, healthRepo)() + + assertEquals(true, alert.shouldDeload) + assertEquals( + "Volume is up 20% while recovery signals are down. " + + "Consider a deload week: reduce intensity 30\u201340% and prioritize sleep.", + alert.message + ) + } + + @Test + fun `message rounds trend percentage to whole number`() = runTest { + // %.0f on 7.6 renders as "8" + fullyLoadedSetup(trendPct = 7.6) + + val alert = DetectDeloadUseCase(flexRepo, healthRepo)() + + assertEquals(true, alert.message.contains("Volume is up 8%")) + } + + @Test + fun `trend rise of exactly 5 percent does not trigger`() = runTest { + // Threshold is strictly greater than 5.0 + fullyLoadedSetup(trendPct = 5.0, sessions = 10) + + val alert = DetectDeloadUseCase(flexRepo, healthRepo)() + + assertEquals(false, alert.shouldDeload) + } + + @Test + fun `exactly eight sessions counts as high load`() = runTest { + fullyLoadedSetup(trendPct = 10.0, sessions = 8) + + assertEquals(true, DetectDeloadUseCase(flexRepo, healthRepo)().shouldDeload) + } + + @Test + fun `seven sessions is below high load threshold`() = runTest { + fullyLoadedSetup(trendPct = 10.0, sessions = 7) + + assertEquals(false, DetectDeloadUseCase(flexRepo, healthRepo)().shouldDeload) + } + + @Test + fun `poor sleep alone satisfies recovery signal requirement`() = runTest { + fullyLoadedSetup() + health(sleepHours = 5.9, restingHr = null) + + assertEquals(true, DetectDeloadUseCase(flexRepo, healthRepo)().shouldDeload) + } + + @Test + fun `elevated resting heart rate alone satisfies recovery signal requirement`() = runTest { + fullyLoadedSetup() + health(sleepHours = 8.0, restingHr = 76L) + + assertEquals(true, DetectDeloadUseCase(flexRepo, healthRepo)().shouldDeload) + } + + @Test + fun `resting heart rate of exactly 75 is not considered elevated`() = runTest { + fullyLoadedSetup() + health(sleepHours = 8.0, restingHr = 75L) + + assertEquals(false, DetectDeloadUseCase(flexRepo, healthRepo)().shouldDeload) + } + + @Test + fun `no health data prevents deload even with strong volume signal`() = runTest { + fullyLoadedSetup() + health(sleepHours = null, restingHr = null) + + val alert = DetectDeloadUseCase(flexRepo, healthRepo)() + + assertEquals(false, alert.shouldDeload) + assertEquals("", alert.message) + } + + @Test + fun `flat or falling volume never triggers regardless of recovery`() = runTest { + flexRepo.trendToReturn = VolumeTrend(900.0, 1000.0, -10.0) + flexRepo.consistencyToReturn = daysWithWorkouts(12) + health(sleepHours = 4.0, restingHr = 90L) + + assertEquals(false, DetectDeloadUseCase(flexRepo, healthRepo)().shouldDeload) + } + + @Test + fun `volume trend failure treated as no trend`() = runTest { + flexRepo.trendError = IllegalStateException("boom") + flexRepo.consistencyToReturn = daysWithWorkouts(12) + health(sleepHours = 4.0) + + assertEquals(false, DetectDeloadUseCase(flexRepo, healthRepo)().shouldDeload) + } + + @Test + fun `consistency failure treated as zero sessions`() = runTest { + flexRepo.trendToReturn = VolumeTrend(1200.0, 1000.0, 15.0) + flexRepo.consistencyError = IllegalStateException("boom") + health(sleepHours = 4.0) + + assertEquals(false, DetectDeloadUseCase(flexRepo, healthRepo)().shouldDeload) + } + + @Test + fun `consistency window requested is 14 days`() = runTest { + flexRepo.trendToReturn = VolumeTrend(1200.0, 1000.0, 15.0) + flexRepo.consistencyToReturn = daysWithWorkouts(8) + health(sleepHours = 5.0) + + DetectDeloadUseCase(flexRepo, healthRepo)() + + assertEquals(listOf(14), flexRepo.requestedConsistencyDays) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/ExplainWorkoutUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/ExplainWorkoutUseCaseTest.kt new file mode 100644 index 0000000..9f70c8b --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/ExplainWorkoutUseCaseTest.kt @@ -0,0 +1,137 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.ai.HevyAiDataAccessor +import com.jdluu.flexinsight.fakes.FakeFlexAIClient +import com.jdluu.flexinsight.fakes.FakeFlexRepository +import com.jdluu.flexinsight.fakes.FakeWorkoutRepository +import com.jdluu.flexinsight.fakes.TestDefaults.exercise +import com.jdluu.flexinsight.fakes.TestDefaults.set +import com.jdluu.flexinsight.fakes.TestDefaults.workout +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExplainWorkoutUseCaseTest { + + private val flexRepo = FakeFlexRepository() + private val workoutRepo = FakeWorkoutRepository() + private val aiClient = FakeFlexAIClient() + private val accessor = mockk() + private val buildAiContext = BuildAiContextUseCase(accessor) + + private fun useCase() = ExplainWorkoutUseCase(flexRepo, workoutRepo, aiClient, buildAiContext) + + @Test + fun `unknown workout id yields error and skips AI call`() = runTest { + val result = useCase()("missing-id") + + assertTrue(result is Result.Error) + assertEquals( + ApiError.Unknown("Workout not found"), + (result as Result.Error).error + ) + assertEquals(0, aiClient.prompts.size) + coVerify(exactly = 0) { accessor.buildContext(any()) } + } + + @Test + fun `prompt embeds context title exercises and set details`() = runTest { + flexRepo.workoutByIdToReturn = workout("w1", name = "Push Day") + workoutRepo.exercisesByWorkout["w1"] = listOf(exercise("e1", "w1", "Bench Press")) + workoutRepo.setsByExercise["e1"] = listOf( + set("s1", "e1", weight = 100.0, reps = 10, rpe = 8.0), + set("s2", "e1", weight = 80.0, reps = 12, rpe = null) + ) + coEvery { accessor.buildContext("Push Day") } returns HevyAiDataAccessor.ContextSnapshot( + text = "AI CONTEXT", + hasWorkoutData = true, + hasApiKey = true, + workoutCount = 1 + ) + + val result = useCase()("w1") + + assertTrue(result is Result.Success) + assertEquals(Result.Success("AI says hi"), result) + val prompt = aiClient.prompts.single() + assertEquals( + "AI CONTEXT\n\n" + + "Explain this completed workout:\n" + + "Title: Push Day\n" + + "Exercise: Bench Press\n" + + " - 100.0kg x 10 reps RPE 8.0\n" + + " - 80.0kg x 12 reps RPE -\n" + + "\n\nProvide a concise analysis: highlights, progression, and one actionable tip.", + prompt + ) + } + + @Test + fun `workout name is used as live history query`() = runTest { + flexRepo.workoutByIdToReturn = workout("w1", name = "Legs") + workoutRepo.exercisesByWorkout["w1"] = emptyList() + coEvery { accessor.buildContext("Legs") } returns HevyAiDataAccessor.ContextSnapshot( + text = "", hasWorkoutData = false, hasApiKey = true, workoutCount = 0 + ) + + useCase()("w1") + + coVerify(exactly = 1) { accessor.buildContext("Legs") } + } + + @Test + fun `null workout name falls back to generic query`() = runTest { + // NOTE: possibly unintended - the fallback string "workout" is used verbatim as an + // exercise-history query token, which can match unrelated template names + flexRepo.workoutByIdToReturn = workout("w1", name = null) + workoutRepo.exercisesByWorkout["w1"] = emptyList() + coEvery { accessor.buildContext("workout") } returns HevyAiDataAccessor.ContextSnapshot( + text = "", hasWorkoutData = false, hasApiKey = true, workoutCount = 0 + ) + + useCase()("w1") + + coVerify(exactly = 1) { accessor.buildContext("workout") } + } + + @Test + fun `multiple exercises render in stored order`() = runTest { + flexRepo.workoutByIdToReturn = workout("w1", name = "W") + workoutRepo.exercisesByWorkout["w1"] = listOf( + exercise("e1", "w1", "Squat"), + exercise("e2", "w1", "Plank") + ) + workoutRepo.setsByExercise["e1"] = listOf(set("s1", "e1", weight = 140.0, reps = 5)) + workoutRepo.setsByExercise["e2"] = listOf(set("s2", "e2", weight = null, reps = null)) + coEvery { accessor.buildContext("W") } returns HevyAiDataAccessor.ContextSnapshot( + text = "", hasWorkoutData = true, hasApiKey = true, workoutCount = 1 + ) + + useCase()("w1") + + val prompt = aiClient.prompts.single() + assertTrue(prompt.contains("Exercise: Squat")) + // NOTE: possibly unintended - a bodyweight set renders literally as "nullkg x null reps" + assertTrue(prompt.contains(" - nullkg x null reps RPE -")) + assertTrue(prompt.indexOf("Squat") < prompt.indexOf("Plank")) + } + + @Test + fun `ai client error result is propagated unchanged`() = runTest { + flexRepo.workoutByIdToReturn = workout("w1", name = "N") + coEvery { accessor.buildContext("N") } returns HevyAiDataAccessor.ContextSnapshot( + text = "", hasWorkoutData = false, hasApiKey = true, workoutCount = 0 + ) + aiClient.response = Result.Error(ApiError.Unknown("model busy")) + + val result = useCase()("w1") + + assertEquals(Result.Error(ApiError.Unknown("model busy")), result) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/ExportCoachReportUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/ExportCoachReportUseCaseTest.kt new file mode 100644 index 0000000..e93ae97 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/ExportCoachReportUseCaseTest.kt @@ -0,0 +1,156 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.ai.HevyAiDataAccessor +import com.jdluu.flexinsight.data.health.HealthConnectRepository +import com.jdluu.flexinsight.data.health.HealthConnectSnapshot +import com.jdluu.flexinsight.data.model.PRDetails +import com.jdluu.flexinsight.data.model.WeeklyGoalProgress +import com.jdluu.flexinsight.data.model.WorkoutStats +import com.jdluu.flexinsight.fakes.FakeFlexAIClient +import com.jdluu.flexinsight.fakes.FakeFlexRepository +import com.jdluu.flexinsight.domain.ai.AiContextProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ExportCoachReportUseCaseTest { + + private val flexRepo = FakeFlexRepository() + private val aiClient = FakeFlexAIClient() + private val accessor = mockk() + private val healthRepo = mockk() + private val originalLocale = Locale.getDefault() + + @Before + fun setUp() { + Locale.setDefault(Locale.US) + coEvery { healthRepo.readSnapshot() } returns HealthConnectSnapshot() + coEvery { accessor.buildContext(any()) } returns HevyAiDataAccessor.ContextSnapshot( + text = "AI CONTEXT", + hasWorkoutData = true, + hasApiKey = true, + workoutCount = 5 + ) + } + + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + + private fun useCase(): ExportCoachReportUseCase { + val load = CalculateTrainingLoadUseCase(flexRepo, healthRepo) + val context = BuildAiContextUseCase(accessor) + return ExportCoachReportUseCase(flexRepo, context, aiClient, load) + } + + private fun today(): String = + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) + + @Test + fun `full report includes summary load prs and ai notes sections`() = runTest { + flexRepo.statsToReturn = WorkoutStats( + totalWorkouts = 12, totalVolume = 1200.0, averageVolume = 100.0, + totalSets = 120, totalDuration = 600L, averageDuration = 50L, + currentStreak = 5, longestStreak = 9, bestWeekVolume = 800.0, bestWeekDate = null + ) + flexRepo.goalToReturn = WeeklyGoalProgress(completed = 3, target = 5, status = "Behind") + flexRepo.prsToReturn = listOf( + PRDetails("Bench Press", 1700000000000L, "Chest", 100.0, "w1", "s1") + ) + aiClient.available = true + aiClient.response = Result.Success("Great week!") + + val report = useCase()() + + val expected = + "# FlexInsight Weekly Coach Report\n" + + "Generated: ${today()}\n" + + "\n" + + "## Summary\n" + + "- Total workouts: 12\n" + + "- Total volume: 1200 kg\n" + + "- Current streak: 5 days\n" + + "\n" + + "## Training load\n" + + "- Overall: 45/100 (Recovery focus)\n" + // 60*0.5 + 0*0.2 + 50*0.3 + "- Hevy volume 60/100\n" + + "\n" + + "## Recent PRs\n" + + "- Bench Press: 100.0 kg\n" + + "\n" + + "## AI Coach Notes\n" + + "Great week!\n" + assertEquals(expected, report) + // PR query uses a hard limit of 10 + assertEquals(listOf(10), flexRepo.requestedPrLimits) + } + + @Test + fun `stats failure omits summary section only`() = runTest { + flexRepo.statsError = IllegalStateException("db down") + flexRepo.goalToReturn = WeeklyGoalProgress(0, 5, "Behind") + + val report = useCase()() + + assertTrue(!report.contains("## Summary")) + assertTrue(report.contains("## Training load")) + } + + @Test + fun `no PRs omits PR section`() = runTest { + flexRepo.prsToReturn = emptyList() + flexRepo.goalToReturn = WeeklyGoalProgress(0, 5, "Behind") + + val report = useCase()() + + assertTrue(!report.contains("## Recent PRs")) + } + + @Test + fun `ai unavailable skips notes section entirely`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(0, 5, "Behind") + aiClient.available = false + + val report = useCase()() + + assertTrue(!report.contains("## AI Coach Notes")) + // NOTE: possibly unintended - AI context is still built even when the model is unavailable + coVerify(exactly = 1) { accessor.buildContext(null) } + } + + @Test + fun `ai error renders unavailable line with error message`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(0, 5, "Behind") + aiClient.available = true + aiClient.response = Result.Error(ApiError.Unknown("model not ready")) + + val report = useCase()() + + assertTrue(report.contains("## AI Coach Notes\nUnavailable (model not ready)\n")) + } + + @Test + fun `ai prompt embeds context and coaching instruction`() = runTest { + flexRepo.goalToReturn = WeeklyGoalProgress(0, 5, "Behind") + aiClient.available = true + + useCase()() + + assertEquals( + "AI CONTEXT\n\nWrite a 3-paragraph weekly coaching summary based on this data. Be specific.", + aiClient.prompts.single() + ) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetMuscleGroupProgressUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetMuscleGroupProgressUseCaseTest.kt new file mode 100644 index 0000000..9b8bcc2 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetMuscleGroupProgressUseCaseTest.kt @@ -0,0 +1,44 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.data.model.MuscleGroupProgress +import com.jdluu.flexinsight.fakes.FakeStatsRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class GetMuscleGroupProgressUseCaseTest { + + @Test + fun `passes weeks argument through and returns repository data`() = runTest { + val repo = FakeStatsRepository() + val data = listOf( + MuscleGroupProgress(muscleGroup = "Chest", volume = 5000.0, sets = 20, intensity = "HI"), + MuscleGroupProgress(muscleGroup = "Back", volume = 3000.0, sets = 15, intensity = "MD") + ) + repo.muscleGroupProgressToReturn = data + val useCase = GetMuscleGroupProgressUseCase(repo) + + val result = useCase(weeks = 6) + + assertEquals(listOf(6), repo.requestedMuscleGroupWeeks) + assertEquals(data, result) + } + + @Test + fun `single item list is returned as-is`() = runTest { + val repo = FakeStatsRepository() + val single = listOf( + MuscleGroupProgress(muscleGroup = "Legs", volume = 12000.0, sets = 40, intensity = "LO") + ) + repo.muscleGroupProgressToReturn = single + + assertEquals(single, GetMuscleGroupProgressUseCase(repo)(weeks = 1)) + } + + @Test + fun `no data returns empty list`() = runTest { + val useCase = GetMuscleGroupProgressUseCase(FakeStatsRepository()) + + assertEquals(emptyList(), useCase(weeks = 4)) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetMuscleRecoveryUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetMuscleRecoveryUseCaseTest.kt new file mode 100644 index 0000000..9da3459 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetMuscleRecoveryUseCaseTest.kt @@ -0,0 +1,31 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.data.model.MuscleGroup +import com.jdluu.flexinsight.fakes.FakeStatsRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class GetMuscleRecoveryUseCaseTest { + + @Test + fun `returns recovery map from repository`() = runTest { + val repo = FakeStatsRepository() + val recovery = mapOf( + MuscleGroup.CHEST to 0.25f, + MuscleGroup.BACK to 0.9f, + MuscleGroup.LEGS to 1.0f + ) + repo.recoveryToReturn = recovery + val useCase = GetMuscleRecoveryUseCase(repo) + + assertEquals(recovery, useCase()) + } + + @Test + fun `empty recovery map is returned when no data`() = runTest { + val useCase = GetMuscleRecoveryUseCase(FakeStatsRepository()) + + assertEquals(emptyMap(), useCase()) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetPRDetailsUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetPRDetailsUseCaseTest.kt new file mode 100644 index 0000000..7ddb55b --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetPRDetailsUseCaseTest.kt @@ -0,0 +1,49 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.data.model.PRDetails +import com.jdluu.flexinsight.fakes.FakeStatsRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class GetPRDetailsUseCaseTest { + + private fun pr(name: String, weight: Double) = PRDetails( + exerciseName = name, + date = 1700000000000L, + muscleGroup = "Chest", + weight = weight, + workoutId = "w1", + setId = "s1" + ) + + @Test + fun `passes limit through and returns repository data`() = runTest { + val repo = FakeStatsRepository() + val prs = listOf(pr("Bench Press", 100.0), pr("Squat", 140.0)) + repo.prsToReturn = prs + val useCase = GetPRDetailsUseCase(repo) + + val result = useCase(limit = 5) + + assertEquals(listOf(5), repo.requestedPrLimits) + assertEquals(prs, result) + } + + @Test + fun `no PRs returns empty list`() = runTest { + val useCase = GetPRDetailsUseCase(FakeStatsRepository()) + + assertEquals(emptyList(), useCase(limit = 10)) + } + + @Test + fun `zero limit value is forwarded unchanged`() = runTest { + val repo = FakeStatsRepository() + val useCase = GetPRDetailsUseCase(repo) + + useCase(limit = 0) + + assertEquals(listOf(0), repo.requestedPrLimits) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetWeeklyProgressUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetWeeklyProgressUseCaseTest.kt new file mode 100644 index 0000000..95f6e47 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetWeeklyProgressUseCaseTest.kt @@ -0,0 +1,43 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.fakes.FakeStatsRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class GetWeeklyProgressUseCaseTest { + + @Test + fun `passes weeks argument through to repository`() = runTest { + val repo = FakeStatsRepository() + repo.weeklyProgressToReturn = listOf( + WeeklyProgress(weekStartDate = 0L, totalVolume = 1000.0, workoutCount = 2, averageVolume = 500.0), + WeeklyProgress(weekStartDate = 604800000L, totalVolume = 2000.0, workoutCount = 3, averageVolume = 666.67) + ) + val useCase = GetWeeklyProgressUseCase(repo) + + val result = useCase(weeks = 8) + + assertEquals(listOf(8), repo.requestedWeeklyProgressWeeks) + assertEquals(2, result.size) + assertEquals(2000.0, result[1].totalVolume, 0.001) + } + + @Test + fun `empty data returns empty list`() = runTest { + val useCase = GetWeeklyProgressUseCase(FakeStatsRepository()) + + assertEquals(emptyList(), useCase(weeks = 4)) + } + + @Test + fun `zero weeks value is forwarded unchanged`() = runTest { + val repo = FakeStatsRepository() + val useCase = GetWeeklyProgressUseCase(repo) + + useCase(weeks = 0) + + assertEquals(listOf(0), repo.requestedWeeklyProgressWeeks) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetWorkoutStatsUseCaseTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetWorkoutStatsUseCaseTest.kt new file mode 100644 index 0000000..1d25767 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/usecase/GetWorkoutStatsUseCaseTest.kt @@ -0,0 +1,32 @@ +package com.jdluu.flexinsight.domain.usecase + +import com.jdluu.flexinsight.fakes.FakeStatsRepository +import com.jdluu.flexinsight.fakes.TestDefaults +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class GetWorkoutStatsUseCaseTest { + + @Test + fun `returns stats calculated by repository`() = runTest { + val repo = FakeStatsRepository() + val stats = TestDefaults.emptyStats.copy(totalWorkouts = 7, totalVolume = 4321.0) + repo.statsToReturn = stats + val useCase = GetWorkoutStatsUseCase(repo) + + val result = useCase() + + assertSame(stats, result) + } + + @Test + fun `empty repository data yields zeroed stats`() = runTest { + val useCase = GetWorkoutStatsUseCase(FakeStatsRepository()) + + val result = useCase() + + assertEquals(TestDefaults.emptyStats, result) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/util/ExerciseTemplateMatcherTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/util/ExerciseTemplateMatcherTest.kt index 2ecf755..ba684ee 100644 --- a/app/src/test/java/com/jdluu/flexinsight/domain/util/ExerciseTemplateMatcherTest.kt +++ b/app/src/test/java/com/jdluu/flexinsight/domain/util/ExerciseTemplateMatcherTest.kt @@ -6,23 +6,101 @@ import org.junit.Test class ExerciseTemplateMatcherTest { - private val mapping = mapOf( + private val mapping = linkedMapOf( "bench-id" to "Bench Press (Barbell)", "squat-id" to "Squat (Barbell)" ) + // ---- Guard clauses ---- + @Test - fun resolveTemplateId_exactMatch() { - assertEquals("bench-id", ExerciseTemplateMatcher.resolveTemplateId("Bench Press (Barbell)", mapping)) + fun `blank exercise name returns null`() { + assertNull(ExerciseTemplateMatcher.resolveTemplateId("", mapping)) + assertNull(ExerciseTemplateMatcher.resolveTemplateId(" ", mapping)) } @Test - fun resolveTemplateId_partialMatch() { - assertEquals("squat-id", ExerciseTemplateMatcher.resolveTemplateId("Squat", mapping)) + fun `empty lookup map returns null`() { + assertNull(ExerciseTemplateMatcher.resolveTemplateId("Bench Press", emptyMap())) } @Test - fun resolveTemplateId_returnsNullWhenUnknown() { + fun `unknown exercise returns null`() { assertNull(ExerciseTemplateMatcher.resolveTemplateId("Lat Pulldown", mapping)) } + + // ---- Exact matching ---- + + @Test + fun `exact match ignoring case resolves template id`() { + assertEquals( + "bench-id", + ExerciseTemplateMatcher.resolveTemplateId("BENCH PRESS (BARBELL)", mapping) + ) + } + + @Test + fun `surrounding whitespace is trimmed before matching`() { + assertEquals("squat-id", ExerciseTemplateMatcher.resolveTemplateId(" Squat (Barbell) ", mapping)) + } + + @Test + fun `exact match wins regardless of map insertion order`() { + val reversed = linkedMapOf( + "other-id" to "Incline Bench Press", + "bench-id" to "Bench Press" + ) + assertEquals("bench-id", ExerciseTemplateMatcher.resolveTemplateId("Bench Press", reversed)) + } + + // ---- Substring matching ---- + + @Test + fun `query containing template name resolves`() { + assertEquals("squat-id", ExerciseTemplateMatcher.resolveTemplateId("Squat", mapping)) + assertEquals("bench-id", ExerciseTemplateMatcher.resolveTemplateId("Paused Bench Press (Barbell)", mapping)) + } + + @Test + fun `template name contained inside longer query resolves either direction`() { + assertEquals("bench-id", ExerciseTemplateMatcher.resolveTemplateId("Barbell", mapping)) + } + + @Test + fun `substring matching is case insensitive in both directions`() { + assertEquals("bench-id", ExerciseTemplateMatcher.resolveTemplateId("bench press", mapping)) + } + + @Test + fun `first substring hit in insertion order wins`() { + val many = linkedMapOf( + "a-id" to "Press", + "b-id" to "Bench" + ) + // "Press" is encountered first and query.contains("Press") holds + assertEquals("a-id", ExerciseTemplateMatcher.resolveTemplateId("Bench Press", many)) + } + + // ---- Quirks worth pinning down ---- + + @Test + fun `empty template title acts as wildcard substring match`() { + // Exact matching runs first, so a true exact hit still wins over an empty title: + assertEquals("bench-id", ExerciseTemplateMatcher.resolveTemplateId("Bench Press", linkedMapOf("weird-id" to "", "bench-id" to "Bench Press"))) + // For non-exact queries the ""-title entry matches everything via contains("") + // NOTE: possibly unintended - an empty stored title shadows real templates on substring passes + val withEmptyTitle = linkedMapOf( + "weird-id" to "", + "bench-id" to "Bench Press" + ) + assertEquals("weird-id", ExerciseTemplateMatcher.resolveTemplateId("Incline Bench Press", withEmptyTitle)) + } + + @Test + fun `single word query matches template by prefix containment`() { + val templates = linkedMapOf("dl-id" to "Deadlift") + assertEquals("dl-id", ExerciseTemplateMatcher.resolveTemplateId("deadlift", templates)) + // Neither direction contains the other + assertNull(ExerciseTemplateMatcher.resolveTemplateId("Row", templates)) + } } diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/util/StatsCalculatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/util/StatsCalculatorTest.kt new file mode 100644 index 0000000..9ba8e54 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/util/StatsCalculatorTest.kt @@ -0,0 +1,563 @@ +package com.jdluu.flexinsight.domain.util + +import com.jdluu.flexinsight.data.model.DailyDurationData +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.MuscleGroupProgress +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.model.Workout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.util.TimeZone + +/** + * Characterization tests for [StatsCalculator]. Timezone is pinned to UTC so that + * day-boundary math is deterministic; production behavior reads ZoneId.systemDefault(). + */ +class StatsCalculatorTest { + + private val originalTimeZone = TimeZone.getDefault() + + @After + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + private fun pinUtc() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + private fun zone(): ZoneId = ZoneId.systemDefault() + + private fun millis(date: LocalDate, hour: Int = 10): Long = + date.atTime(hour, 0).atZone(zone()).toInstant().toEpochMilli() + + private fun workout( + id: String, + startTime: Long, + endTime: Long? = startTime + 3_600_000L, + routineId: String? = null, + isDeleted: Boolean = false + ) = Workout( + id = id, + name = "Workout $id", + startTime = startTime, + endTime = endTime, + notes = null, + routineId = routineId, + isDeleted = isDeleted + ) + + private fun exercise(id: String, workoutId: String) = Exercise( + id = id, + workoutId = workoutId, + exerciseTemplateId = null, + name = "Exercise $id", + notes = null, + restDuration = null + ) + + private fun set(id: String, exerciseId: String, weight: Double?, reps: Int?) = Set( + id = id, + exerciseId = exerciseId, + number = 1, + weight = weight, + reps = reps, + rpe = null, + distance = null, + duration = null, + restDuration = null, + notes = null + ) + + // ---- calculateTotalVolume ---- + + @Test + fun totalVolume_emptyWorkoutsIsZero() { + val volume = StatsCalculator.calculateTotalVolume( + workouts = emptyList(), + allExercises = listOf(exercise("e1", "w1")), + allSets = listOf(set("s1", "e1", 100.0, 10)) + ) + assertEquals(0.0, volume, 0.0001) + } + + @Test + fun totalVolume_sumsAcrossWorkoutsExercisesAndSets() { + val w1 = workout("w1", 0) + val w2 = workout("w2", 100) + val sets = listOf( + set("s1", "e1", 100.0, 10), // 1000 + set("s2", "e1", 50.0, 10), // 500 + set("s3", "e2", 20.0, 5), // 100 + set("s4", "e3", 30.0, 10) // 300 + ) + val exercises = listOf(exercise("e1", "w1"), exercise("e2", "w1"), exercise("e3", "w2")) + + assertEquals(1900.0, StatsCalculator.calculateTotalVolume(listOf(w1, w2), exercises, sets), 0.0001) + } + + @Test + fun totalVolume_ignoresOrphanExercisesAndSets() { + val w1 = workout("w1", 0) + + val volume = StatsCalculator.calculateTotalVolume( + listOf(w1), + listOf(exercise("orphan-e", "unknown-workout")), + listOf(set("orphan-s", "unknown-exercise", 999.0, 99)) + ) + assertEquals(0.0, volume, 0.0001) + } + + @Test + fun totalVolume_nullWeightOrRepsCountAsZero() { + val w1 = workout("w1", 0) + val sets = listOf( + set("s1", "e1", null, 10), // null weight -> 0 + set("s2", "e1", 50.0, null), // null reps -> 0 + set("s3", "e1", 25.0, 8) // 200 + ) + + assertEquals(200.0, StatsCalculator.calculateTotalVolume(listOf(w1), listOf(exercise("e1", "w1")), sets), 0.0001) + } + + @Test + fun totalVolume_accumulatesFractionalWeights() { + val w1 = workout("w1", 0) + val sets = listOf(set("s1", "e1", 12.5, 4), set("s2", "e1", 7.25, 2)) + + assertEquals(64.5, StatsCalculator.calculateTotalVolume(listOf(w1), listOf(exercise("e1", "w1")), sets), 0.0001) + } + + // ---- calculateTotalDuration ---- + + @Test + fun totalDuration_emptyListIsZero() { + assertEquals(0L, StatsCalculator.calculateTotalDuration(emptyList())) + } + + @Test + fun totalDuration_skipsWorkoutsWithoutEndTime() { + val open = workout("open", 0, endTime = null) + val closed = workout("closed", 0, endTime = 30 * 60 * 1000L) + + assertEquals(30L, StatsCalculator.calculateTotalDuration(listOf(open, closed))) + } + + @Test + fun totalDuration_truncatesPartialMinutes() { + // 90 minutes and 59 seconds -> 90 + val w = workout("w", 0, endTime = 90 * 60 * 1000L + 59_999L) + + assertEquals(90L, StatsCalculator.calculateTotalDuration(listOf(w))) + } + + @Test + fun totalDuration_sumsMultipleWorkouts() { + val a = workout("a", 0, endTime = 45 * 60 * 1000L) + val b = workout("b", 1000, endTime = 1000 + 75 * 60 * 1000L) + + assertEquals(120L, StatsCalculator.calculateTotalDuration(listOf(a, b))) + } + + // ---- calculateStreak ---- + + @Test + fun streak_emptyHistoryIsZero() { + assertEquals(0, StatsCalculator.calculateStreak(emptyList())) + } + + @Test + fun streak_staleLatestDayBreaksStreakImmediately() { + pinUtc() + val today = LocalDate.now() + val old = today.minusDays(5) + + assertEquals(0, StatsCalculator.calculateStreak(listOf(workout("old", millis(old))))) + } + + @Test + fun streak_countsBackThroughConsecutiveDaysFromToday() { + pinUtc() + val today = LocalDate.now() + val days = listOf(today, today.minusDays(1), today.minusDays(2)) + + assertEquals(3, StatsCalculator.calculateStreak(days.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun streak_startedYesterdayStillCountsWhenTodayHasNoWorkout() { + pinUtc() + val today = LocalDate.now() + val days = listOf(today.minusDays(1), today.minusDays(2)) + + assertEquals(2, StatsCalculator.calculateStreak(days.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun streak_multipleSessionsPerDayCollapseToOneDate() { + pinUtc() + val today = LocalDate.now() + val sameDayTwice = listOf( + workout("am", millis(today, hour = 8)), + workout("pm", millis(today, hour = 20)) + ) + + assertEquals(1, StatsCalculator.calculateStreak(sameDayTwice)) + } + + @Test + fun streak_missedDayTruncatesRun() { + pinUtc() + val today = LocalDate.now() + val days = listOf(today, today.minusDays(1), today.minusDays(3), today.minusDays(4)) + + assertEquals(2, StatsCalculator.calculateStreak(days.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun streak_handlesUnsortedInput() { + pinUtc() + val today = LocalDate.now() + val days = listOf(today.minusDays(2), today, today.minusDays(1)) + + assertEquals(3, StatsCalculator.calculateStreak(days.map { workout(it.toString(), millis(it)) })) + } + + // ---- calculateLongestStreak ---- + + @Test + fun longestStreak_emptyHistoryIsZero() { + assertEquals(0, StatsCalculator.calculateLongestStreak(emptyList())) + } + + @Test + fun longestStreak_singleWorkoutIsOne() { + assertEquals(1, StatsCalculator.calculateLongestStreak(listOf(workout("w", 0)))) + } + + @Test + fun longestStreak_findsLongestRunAnywhereInHistory() { + pinUtc() + val base = LocalDate.of(2026, 1, 5) // Monday + val run1 = (0..2).map { base.plusDays(it.toLong()) } // 3-day run + val run2 = (0..4).map { base.plusDays(10 + it.toLong()) } // 5-day run + val dates = run1 + run2 + + assertEquals(5, StatsCalculator.calculateLongestStreak(dates.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun longestStreak_fullyConsecutiveHistoryReturnsEntireLength() { + pinUtc() + val base = LocalDate.of(2026, 3, 1) + val dates = (0..6).map { base.plusDays(it.toLong()) } + + assertEquals(7, StatsCalculator.calculateLongestStreak(dates.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun longestStreak_sameDayDuplicatesDoNotInflateStreak() { + pinUtc() + val base = LocalDate.of(2026, 2, 1) + val doubled = listOf(base, base, base.plusDays(1)) + + assertEquals(2, StatsCalculator.calculateLongestStreak(doubled.map { workout(it.toString(), millis(it)) })) + } + + // ---- calculateVolumeBalance ---- + + @Test + fun volumeBalance_emptyInputDefaultsToQuarterSplit() { + assertEquals(VolumeBalance(0.25f, 0.25f, 0.25f, 0.25f), StatsCalculator.calculateVolumeBalance(emptyList())) + } + + @Test + fun volumeBalance_categorizesPushPullLegsCardio() { + val progress = listOf( + MuscleGroupProgress("Chest", 300.0, 10, "MD"), + MuscleGroupProgress("Shoulders", 100.0, 5, "LO"), + MuscleGroupProgress("Back", 200.0, 8, "MD"), + MuscleGroupProgress("Biceps", 100.0, 4, "LO"), + MuscleGroupProgress("Quads", 400.0, 12, "HI"), + MuscleGroupProgress("Cardio", 50.0, 1, "LO") + ) + + val balance = StatsCalculator.calculateVolumeBalance(progress) + + assertEquals(400.0f / 1150.0f, balance.push, 0.0001f) + assertEquals(300.0f / 1150.0f, balance.pull, 0.0001f) + assertEquals(400.0f / 1150.0f, balance.legs, 0.0001f) + assertEquals(50.0f / 1150.0f, balance.cardio, 0.0001f) + } + + @Test + fun volumeBalance_matchingIsCaseInsensitiveOnSubstrings() { + val progress = listOf( + MuscleGroupProgress("upper chest", 600.0, 6, "HI"), + MuscleGroupProgress("HAMSTRINGS", 200.0, 6, "MD") + ) + + val balance = StatsCalculator.calculateVolumeBalance(progress) + + assertEquals(0.75f, balance.push, 0.0001f) + assertEquals(0.25f, balance.legs, 0.0001f) + } + + @Test + fun volumeBalance_unknownCategoriesAreExcludedFromSplit() { + val progress = listOf(MuscleGroupProgress("Neck", 900.0, 3, "LO")) + + // Total categorized volume is zero -> falls back to the default quarter split + assertEquals(VolumeBalance(0.25f, 0.25f, 0.25f, 0.25f), StatsCalculator.calculateVolumeBalance(progress)) + } + + @Test + fun volumeBalance_categoryCheckOrderPrefersPushOverPullOverLegs() { + // A group name containing keywords from multiple categories resolves to the first + // matching branch: push ("Chest"/"Shoulders"/"Triceps") beats pull beats legs. + val tricky = listOf( + MuscleGroupProgress("Back Chest Combo", 100.0, 1, "MD"), + MuscleGroupProgress("Legs Back Hybrid", 100.0, 1, "MD") + ) + + val balance = StatsCalculator.calculateVolumeBalance(tricky) + + assertEquals(0.5f, balance.push, 0.0001f) // "Back Chest Combo" contains "Chest" -> push + assertEquals(0.5f, balance.pull, 0.0001f) // "Legs Back Hybrid" contains "Back" -> pull + assertEquals(0.0f, balance.legs, 0.0001f) + } + + // ---- calculateDurationTrend ---- + + @Test + fun durationTrend_returnsSixEntriesMondayThroughSaturday() { + // NOTE: possibly unintended - Sunday is aggregated internally but never emitted, + // so Sunday sessions are invisible in the trend output + val result = StatsCalculator.calculateDurationTrend(emptyList(), 0, Long.MAX_VALUE) + + assertEquals( + listOf("M", "T", "W", "T", "F", "S"), + result.map { it.dayOfWeek } + ) + } + + @Test + fun durationTrend_sundayWorkoutsAreDroppedFromOutput() { + pinUtc() + val monday = LocalDate.of(2026, 1, 5) // Monday + val sunday = LocalDate.of(2026, 1, 11) // Sunday of that week + val start = millis(monday) + val end = millis(sunday, hour = 23) + + val result = StatsCalculator.calculateDurationTrend( + listOf(workout("sun", millis(sunday), millis(sunday, hour = 11))), + start, + end + ) + + // No Monday..Saturday entry carries the Sunday session's average + assertEquals((1..6).map { 0L }, result.map { it.averageDuration }) + } + + @Test + fun durationTrend_averagesPerWeekdayAndTruncatesFractionalMinutes() { + pinUtc() + val monday = LocalDate.of(2026, 1, 5) + val wednesday = LocalDate.of(2026, 1, 7) + val start = millis(monday, hour = 0) + val end = millis(wednesday, hour = 23) + + val workouts = listOf( + workout("m1", millis(monday, hour = 9), millis(monday, hour = 9) + 61 * 60_000L), + workout("m2", millis(monday, hour = 15), millis(monday, hour = 15) + 60 * 60_000L), + workout("w1", millis(wednesday, hour = 12), millis(wednesday, hour = 13)) + ) + + val result = StatsCalculator.calculateDurationTrend(workouts, start, end) + + assertEquals( + listOf( + DailyDurationData("M", 60L), // avg(61, 60) = 60.5 truncated to 60 + DailyDurationData("T", 0L), + DailyDurationData("W", 60L), + DailyDurationData("T", 0L), + DailyDurationData("F", 0L), + DailyDurationData("S", 0L) + ), + result + ) + } + + @Test + fun durationTrend_windowBoundsAreInclusive() { + pinUtc() + val monday = LocalDate.of(2026, 1, 5) + val wednesday = LocalDate.of(2026, 1, 7) + val start = millis(monday, hour = 0) + val end = millis(wednesday, hour = 23) + + val workouts = listOf( + workout("at-start", millis(monday, hour = 6), millis(monday, hour = 7)), + workout("at-end", millis(wednesday, hour = 22), millis(wednesday, hour = 23)), + workout("before-window", millis(monday.minusDays(1)), millis(monday.minusDays(1), hour = 12)), + workout("after-window", millis(wednesday.plusDays(1)), millis(wednesday.plusDays(1), hour = 12)) + ) + + val result = StatsCalculator.calculateDurationTrend(workouts, start, end) + + // Monday and Wednesday both carry data; Tuesday stays empty + assertEquals(60L, result[0].averageDuration) + assertEquals(0L, result[1].averageDuration) + assertEquals(60L, result[2].averageDuration) + } + + @Test + fun durationTrend_excludesWorkoutsMissingEndTime() { + pinUtc() + val monday = LocalDate.of(2026, 1, 5) + + val result = StatsCalculator.calculateDurationTrend( + listOf(workout("open-ended", millis(monday), endTime = null)), + millis(monday, hour = 0), + millis(monday, hour = 23) + ) + + assertEquals(0L, result[0].averageDuration) + } + + // ---- getStartOfDay / getEndOfDay ---- + + @Test + fun startOfDay_mapsToMidnightOfSameLocalDate() { + pinUtc() + val timestamp = Instant.parse("2026-06-15T14:30:45Z").toEpochMilli() + + val expected = LocalDate.of(2026, 6, 15).atStartOfDay(zone()).toInstant().toEpochMilli() + + assertEquals(expected, StatsCalculator.getStartOfDay(timestamp)) + } + + @Test + fun endOfDay_isLastMillisecondOfSameLocalDate() { + pinUtc() + val date = LocalDate.of(2026, 6, 15) + val timestamp = millis(date, hour = 18) + + val start = StatsCalculator.getStartOfDay(timestamp) + val end = StatsCalculator.getEndOfDay(timestamp) + + // LocalTime.MAX truncates to milliseconds when converted to epoch millis + assertEquals(start + 86_399_999L, end) + assertEquals(date.atTime(LocalTime.MAX).atZone(zone()).toInstant().toEpochMilli(), end) + } + + // ---- calculateVolumeChange ---- + + @Test + fun volumeChange_positiveDeltaYieldsPositivePercent() { + assertEquals(25.0, StatsCalculator.calculateVolumeChange(1250.0, 1000.0), 0.0001) + } + + @Test + fun volumeChange_negativeDeltaYieldsNegativePercent() { + assertEquals(-40.0, StatsCalculator.calculateVolumeChange(600.0, 1000.0), 0.0001) + } + + @Test + fun volumeChange_zeroPreviousWithCurrentReturns100() { + assertEquals(100.0, StatsCalculator.calculateVolumeChange(500.0, 0.0), 0.0001) + } + + @Test + fun volumeChange_bothZeroReturnsZero() { + assertEquals(0.0, StatsCalculator.calculateVolumeChange(0.0, 0.0), 0.0001) + } + + // ---- calculateGoalStatus ---- + + @Test + fun goalStatus_atTargetIsOnTrack() { + assertEquals("On Track", StatsCalculator.calculateGoalStatus(completed = 5, target = 5)) + } + + @Test + fun goalStatus_exactlySeventyPercentIsOnTrack() { + assertEquals("On Track", StatsCalculator.calculateGoalStatus(completed = 7, target = 10)) + } + + @Test + fun goalStatus_betweenFiftyAndSeventyPercentIsBehind() { + // NOTE: possibly unintended - the function can never return "Ahead", and the + // >=target and >=70% branches are duplicates of each other + assertEquals("Behind", StatsCalculator.calculateGoalStatus(completed = 6, target = 10)) + } + + @Test + fun goalStatus_belowFiftyPercentIsBehind() { + assertEquals("Behind", StatsCalculator.calculateGoalStatus(completed = 4, target = 10)) + } + + @Test + fun goalStatus_zeroCompletedIsBehind() { + assertEquals("Behind", StatsCalculator.calculateGoalStatus(completed = 0, target = 10)) + } + + // ---- calculateRelativeIntensity ---- + + @Test + fun relativeIntensity_onePointFiveTimesAverageIsHigh() { + assertEquals("HI", StatsCalculator.calculateRelativeIntensity(volume = 1500.0, averageVolume = 1000.0)) + } + + @Test + fun relativeIntensity_seventyToHundredFortyNinePercentIsMedium() { + assertEquals("MD", StatsCalculator.calculateRelativeIntensity(volume = 700.0, averageVolume = 1000.0)) + assertEquals("MD", StatsCalculator.calculateRelativeIntensity(volume = 1499.99, averageVolume = 1000.0)) + } + + @Test + fun relativeIntensity_belowSeventyPercentIsLow() { + assertEquals("LO", StatsCalculator.calculateRelativeIntensity(volume = 699.99, averageVolume = 1000.0)) + } + + @Test + fun relativeIntensity_boundariesAreInclusive() { + assertEquals("HI", StatsCalculator.calculateRelativeIntensity(volume = 1500.0, averageVolume = 1000.0)) + assertEquals("MD", StatsCalculator.calculateRelativeIntensity(volume = 700.0, averageVolume = 1000.0)) + } + + @Test + fun relativeIntensity_zeroAverageMarksEverythingHigh() { + // NOTE: possibly unintended - with no historical average, even zero volume scores "HI" + assertEquals("HI", StatsCalculator.calculateRelativeIntensity(volume = 0.0, averageVolume = 0.0)) + } + + // ---- calculateAbsoluteIntensity ---- + + @Test + fun absoluteIntensity_aboveFiveThousandIsHigh() { + assertEquals("High Intensity", StatsCalculator.calculateAbsoluteIntensity(5000.01)) + } + + @Test + fun absoluteIntensity_exactlyFiveThousandIsMedium() { + assertEquals("Medium Intensity", StatsCalculator.calculateAbsoluteIntensity(5000.0)) + } + + @Test + fun absoluteIntensity_aboveTwoThousandIsMedium() { + assertEquals("Medium Intensity", StatsCalculator.calculateAbsoluteIntensity(2000.01)) + } + + @Test + fun absoluteIntensity_exactlyTwoThousandOrBelowIsAerobic() { + assertEquals("Aerobic", StatsCalculator.calculateAbsoluteIntensity(2000.0)) + assertEquals("Aerobic", StatsCalculator.calculateAbsoluteIntensity(0.0)) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/fakes/DomainTestFakes.kt b/app/src/test/java/com/jdluu/flexinsight/fakes/DomainTestFakes.kt new file mode 100644 index 0000000..d6e48eb --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/fakes/DomainTestFakes.kt @@ -0,0 +1,292 @@ +package com.jdluu.flexinsight.fakes + +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.ai.AiFeatureStatus +import com.jdluu.flexinsight.data.ai.FlexAIClient +import com.jdluu.flexinsight.data.model.DayInfo +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.MuscleGroup +import com.jdluu.flexinsight.data.model.MuscleGroupProgress +import com.jdluu.flexinsight.data.model.PRDetails +import com.jdluu.flexinsight.data.model.PeriodComparison +import com.jdluu.flexinsight.data.model.PlannedWorkout +import com.jdluu.flexinsight.data.model.ProfileInfo +import com.jdluu.flexinsight.data.model.Routine +import com.jdluu.flexinsight.data.model.RoutineFolder +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.SingleWorkoutStats +import com.jdluu.flexinsight.data.model.Workout +import com.jdluu.flexinsight.data.model.WorkoutResponse +import com.jdluu.flexinsight.data.model.WorkoutStats +import com.jdluu.flexinsight.data.model.DailyDurationData +import com.jdluu.flexinsight.data.model.ExerciseHistoryResponse +import com.jdluu.flexinsight.data.model.WeeklyGoalProgress +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.data.model.WeeklyVolumeData +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.model.VolumeTrend +import com.jdluu.flexinsight.data.repository.FlexRepository +import com.jdluu.flexinsight.data.repository.StatsRepository +import com.jdluu.flexinsight.data.repository.WorkoutRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf + +/** Zero-argument defaults so fakes only need the members a given test cares about. */ +object TestDefaults { + val emptyStats = WorkoutStats( + totalWorkouts = 0, + totalVolume = 0.0, + averageVolume = 0.0, + totalSets = 0, + totalDuration = 0L, + averageDuration = 0L, + currentStreak = 0, + longestStreak = 0, + bestWeekVolume = 0.0, + bestWeekDate = null + ) + + fun workout( + id: String, + name: String? = "Workout $id", + startTime: Long = 0L, + endTime: Long? = null, + routineId: String? = null, + isDeleted: Boolean = false + ) = Workout( + id = id, + name = name, + startTime = startTime, + endTime = endTime, + notes = null, + routineId = routineId, + isDeleted = isDeleted + ) + + fun exercise(id: String, workoutId: String, name: String = "Exercise $id") = Exercise( + id = id, + workoutId = workoutId, + exerciseTemplateId = null, + name = name, + notes = null, + restDuration = null + ) + + fun set( + id: String, + exerciseId: String, + weight: Double? = null, + reps: Int? = null, + rpe: Double? = null, + number: Int = 1 + ) = Set( + id = id, + exerciseId = exerciseId, + number = number, + weight = weight, + reps = reps, + rpe = rpe, + distance = null, + duration = null, + restDuration = null, + notes = null + ) +} + +class FakeFlexRepository : FlexRepository { + var statsToReturn: WorkoutStats = TestDefaults.emptyStats + var statsError: Exception? = null + + var goalToReturn: WeeklyGoalProgress? = WeeklyGoalProgress(0, 5, "Behind") + var goalError: Exception? = null + + var trendToReturn: VolumeTrend = VolumeTrend(currentVolume = 0.0, previousVolume = 0.0, percentageChange = 0.0) + var trendError: Exception? = null + + var consistencyToReturn: List = emptyList() + var consistencyError: Exception? = null + + var prsToReturn: List = emptyList() + + var workoutByIdToReturn: Workout? = null + + val requestedConsistencyDays = mutableListOf() + val requestedPrLimits = mutableListOf() + + override suspend fun calculateStats(): WorkoutStats { + statsError?.let { throw it } + return statsToReturn + } + + override suspend fun getWeeklyGoalProgress(target: Int): WeeklyGoalProgress { + goalError?.let { throw it } + return goalToReturn ?: throw IllegalStateException("No goal configured") + } + + override suspend fun calculateVolumeTrend(weeks: Int): VolumeTrend { + trendError?.let { throw it } + return trendToReturn + } + + override suspend fun getConsistencyData(days: Int): List { + requestedConsistencyDays += days + consistencyError?.let { throw it } + return consistencyToReturn + } + + override suspend fun getPRsWithDetails(limit: Int): List { + requestedPrLimits += limit + return prsToReturn + } + + override suspend fun getWorkoutById(workoutId: String): Workout? = workoutByIdToReturn + + // Unused by the use cases under test; benign defaults keep the fake honest. + override fun invalidateApiService() {} + override fun getWorkouts(): Flow> = flowOf(emptyList()) + override fun getRecentWorkouts(limit: Int): Flow> = flowOf(emptyList()) + override fun getAllExercises(): Flow> = flowOf(emptyList()) + override suspend fun getExerciseHistory(templateId: String): Result = + Result.Error(ApiError.Unknown("not faked")) + override fun getWorkoutByIdFlow(workoutId: String): Flow = flowOf(null) + override fun getWorkoutCount(): Flow = flowOf(0) + override suspend fun getExercisesByWorkoutId(workoutId: String): List = emptyList() + override suspend fun getSetsByExerciseId(exerciseId: String): List = emptyList() + override suspend fun updateWorkoutStatus(workoutId: String, isCompleted: Boolean): Result = + Result.Success(Unit) + override suspend fun rescheduleWorkout(workoutId: String, newStartTime: Long): Result = + Result.Success(Unit) + override suspend fun calculateWorkoutStats(workout: Workout): SingleWorkoutStats = + SingleWorkoutStats(0, 0, 0.0) + override fun getRecentPRs(limit: Int): Flow> = flowOf(emptyList()) + override suspend fun getAllPRsWithDetails(): List = emptyList() + override suspend fun getMuscleGroupProgress(weeks: Int): List = emptyList() + override suspend fun getPeriodComparison(): PeriodComparison? = null + override suspend fun getWeeklyVolumeData(weeks: Int): List = emptyList() + override suspend fun getDurationTrend(weeks: Int): List = emptyList() + override suspend fun getWeekCalendarData(): List = emptyList() + override suspend fun getPlannedWorkoutsForDay(timestamp: Long): List = emptyList() + override suspend fun getVolumeBalance(weeks: Int): VolumeBalance = VolumeBalance(0f, 0f, 0f, 0f) + override suspend fun getWeeklyProgress(weeks: Int): List = emptyList() + override suspend fun getMemberSinceDate(): Long? = null + override suspend fun calculateAccountAgeDays(): Int = 0 + override suspend fun getProfileInfo(): ProfileInfo = + ProfileInfo(null, null, false, 0, 0) + override suspend fun getMuscleRecoveryStatus(): Map = emptyMap() + override fun getRoutines(): Flow> = flowOf(emptyList()) + override suspend fun getRoutineById(routineId: String): Routine? = null + override suspend fun getRoutineFolders(): List = emptyList() + override suspend fun syncAllData(): Result = Result.Success(Unit) + override fun clearCache() {} + override suspend fun syncWithCloud() {} +} + +class FakeWorkoutRepository : WorkoutRepository { + /** Backed by StateFlow so tests can emit values and observe them with Turbine. */ + val workoutsFlow = MutableStateFlow>(emptyList()) + + val exercisesByWorkout = mutableMapOf>() + val setsByExercise = mutableMapOf>() + + override fun getWorkouts(): Flow> = workoutsFlow + + override suspend fun getExercisesByWorkoutId(workoutId: String): List = + exercisesByWorkout[workoutId] ?: emptyList() + + override suspend fun getSetsByExerciseId(exerciseId: String): List = + setsByExercise[exerciseId] ?: emptyList() + + override fun invalidateApiService() {} + override fun getRecentWorkouts(limit: Int): Flow> = flowOf(emptyList()) + override suspend fun getWorkoutById(workoutId: String): Result = + Result.Error(ApiError.Unknown("not faked")) + override fun getWorkoutByIdFlow(workoutId: String): Flow = flowOf(null) + override fun getWorkoutCount(): Flow = flowOf(0) + override suspend fun getRemoteWorkoutCount(): Result = Result.Error(ApiError.Unknown("not faked")) + override fun getWorkoutsByDateRange(startTimestamp: Long, endTimestamp: Long): Flow> = + flowOf(emptyList()) + override suspend fun syncWorkouts(): Result = Result.Success(Unit) + override suspend fun saveWorkoutWithExercisesAndSets(workoutResponse: WorkoutResponse) {} + override suspend fun getMostRecentSyncedTimestamp(): Long? = null + override suspend fun updateWorkoutStatus(workoutId: String, isCompleted: Boolean, endTime: Long?): Result = + Result.Success(Unit) + override suspend fun rescheduleWorkout(workoutId: String, newStartTime: Long): Result = + Result.Success(Unit) +} + +class FakeStatsRepository : StatsRepository { + var statsToReturn: WorkoutStats = TestDefaults.emptyStats + var muscleGroupProgressToReturn: List = emptyList() + var weeklyProgressToReturn: List = emptyList() + var prsToReturn: List = emptyList() + var recoveryToReturn: Map = emptyMap() + + val requestedMuscleGroupWeeks = mutableListOf() + val requestedWeeklyProgressWeeks = mutableListOf() + val requestedPrLimits = mutableListOf() + + override suspend fun calculateStats(): WorkoutStats = statsToReturn + + override suspend fun getMuscleGroupProgress(weeks: Int): List { + requestedMuscleGroupWeeks += weeks + return muscleGroupProgressToReturn + } + + override suspend fun getWeeklyProgress(weeks: Int): List { + requestedWeeklyProgressWeeks += weeks + return weeklyProgressToReturn + } + + override suspend fun getPRsWithDetails(limit: Int): List { + requestedPrLimits += limit + return prsToReturn + } + + override suspend fun getMuscleRecoveryStatus(): Map = recoveryToReturn + + override fun invalidateStatsCache() {} + override suspend fun calculateWorkoutStats(workout: Workout): SingleWorkoutStats = + SingleWorkoutStats(0, 0, 0.0) + override fun getRecentPRs(limit: Int): Flow> = flowOf(emptyList()) + override suspend fun getAllPRsWithDetails(): List = emptyList() + override suspend fun calculateVolumeTrend(weeks: Int): VolumeTrend = + VolumeTrend(0.0, 0.0, 0.0) + override suspend fun getPeriodComparison(): PeriodComparison? = null + override suspend fun getWeeklyVolumeData(weeks: Int): List = emptyList() + override suspend fun getDurationTrend(weeks: Int): List = emptyList() + override suspend fun getWeeklyGoalProgress(target: Int): WeeklyGoalProgress = + WeeklyGoalProgress(0, target, "Behind") + override suspend fun getWeekCalendarData(): List = emptyList() + override suspend fun getPlannedWorkoutsForDay(timestamp: Long): List = emptyList() + override suspend fun getVolumeBalance(weeks: Int): VolumeBalance = VolumeBalance(0f, 0f, 0f, 0f) + override suspend fun getMemberSinceDate(): Long? = null + override suspend fun calculateAccountAgeDays(): Int = 0 + override suspend fun getProfileInfo(hasApiKey: Boolean, remoteWorkoutCount: Int?): ProfileInfo = + ProfileInfo(null, null, hasApiKey, 0, 0) + override suspend fun getConsistencyData(days: Int): List = emptyList() +} + +class FakeFlexAIClient : FlexAIClient { + var available: Boolean = true + var response: Result = Result.Success("AI says hi") + + val prompts = mutableListOf() + + override suspend fun isAvailable(): Boolean = available + + override suspend fun generateResponse( + prompt: String, + history: List> + ): Result { + prompts += prompt + return response + } + + override suspend fun getFeatureStatus(): AiFeatureStatus = AiFeatureStatus.Ready + override suspend fun prepareModel(): Result = Result.Success(Unit) + override suspend fun generateWorkoutPlan(prompt: String): Result = response + override fun generateResponseStream(prompt: String, history: List>) = emptyFlow() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7d24c26..1b79751 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,8 @@ glance = "1.1.1" wearable = "1.1.0" robolectric = "4.14.1" kotlinxSerialization = "1.7.3" +turbine = "1.2.1" +mockk = "1.13.16" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -79,6 +81,8 @@ androidx-test-core = { group = "androidx.test", name = "core", version = "1.6.1" kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }