From fbcdc69b3a7fc31b0b282b459f7a1bbf8fba9800 Mon Sep 17 00:00:00 2001 From: jdluu Date: Tue, 25 Aug 2026 23:10:38 -0700 Subject: [PATCH] test: comprehensive ViewModel state coverage for all screens - Add Turbine-based state-emission tests for Dashboard, History, Planner, PRList, Settings and WorkoutDetail view models (388 total tests, up from 328) - Shared scripted fakes in fakes/VmTestFakes.kt; per-test preference reset isolates the process-wide DataStore between Robolectric tests - runVmTest helper binds Main to the test scheduler so init delays resolve deterministically without real-time polling flakiness --- .../com/jdluu/flexinsight/TestApplication.kt | 20 +- .../jdluu/flexinsight/fakes/VmTestFakes.kt | 361 +++++++++++++ .../ui/viewmodel/DashboardViewModelTest.kt | 340 +++++++++++++ .../ui/viewmodel/HistoryViewModelTest.kt | 337 +++++++++++++ .../ui/viewmodel/PRListViewModelTest.kt | 147 ++++++ .../ui/viewmodel/PlannerViewModelTest.kt | 476 ++++++++++++++++++ .../ui/viewmodel/SettingsViewModelTest.kt | 369 ++++++++++++++ .../viewmodel/WorkoutDetailViewModelTest.kt | 274 ++++++++++ 8 files changed, 2322 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/jdluu/flexinsight/fakes/VmTestFakes.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/DashboardViewModelTest.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/HistoryViewModelTest.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/PRListViewModelTest.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/PlannerViewModelTest.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/SettingsViewModelTest.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/WorkoutDetailViewModelTest.kt diff --git a/app/src/test/java/com/jdluu/flexinsight/TestApplication.kt b/app/src/test/java/com/jdluu/flexinsight/TestApplication.kt index c688dab..61ac185 100644 --- a/app/src/test/java/com/jdluu/flexinsight/TestApplication.kt +++ b/app/src/test/java/com/jdluu/flexinsight/TestApplication.kt @@ -1,6 +1,22 @@ package com.jdluu.flexinsight import android.app.Application +import java.io.File +import java.util.UUID -/** Minimal application for Robolectric unit tests (avoids Hilt/WorkManager init). */ -class TestApplication : Application() +/** + * Minimal application for Robolectric unit tests (avoids Hilt/WorkManager init). + * + * Robolectric shares one temporary directory across the test run, so DataStore-backed + * managers would otherwise reuse one file across test methods: values leak between + * tests and a second active DataStore instance for the same file fails. Pointing + * filesDir at a unique subdirectory per application instance (Robolectric creates one + * per test method) isolates every method's storage. + */ +class TestApplication : Application() { + private val isolatedFilesDir: File by lazy { + File(super.getFilesDir(), "test-" + UUID.randomUUID()).apply { mkdirs() } + } + + override fun getFilesDir(): File = isolatedFilesDir +} diff --git a/app/src/test/java/com/jdluu/flexinsight/fakes/VmTestFakes.kt b/app/src/test/java/com/jdluu/flexinsight/fakes/VmTestFakes.kt new file mode 100644 index 0000000..343de12 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/fakes/VmTestFakes.kt @@ -0,0 +1,361 @@ +package com.jdluu.flexinsight.fakes + +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.core.network.NetworkMonitor +import com.jdluu.flexinsight.core.network.NetworkState +import com.jdluu.flexinsight.data.ai.AiFeatureStatus +import com.jdluu.flexinsight.data.ai.FlexAIClient +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.CreateRoutineRequest +import com.jdluu.flexinsight.data.model.DayInfo +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.ExerciseHistoryResponse +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.DailyDurationData +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.data.model.WeeklyVolumeData +import com.jdluu.flexinsight.data.model.WeeklyGoalProgress +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.model.VolumeTrend +import com.jdluu.flexinsight.data.preferences.ApiKeyManager +import com.jdluu.flexinsight.data.preferences.UserPreferencesManager +import com.jdluu.flexinsight.domain.ai.AiContextProvider +import com.jdluu.flexinsight.data.repository.ExerciseRepository +import com.jdluu.flexinsight.data.repository.FlexRepository +import com.jdluu.flexinsight.data.repository.RoutineRepository +import com.jdluu.flexinsight.widget.WidgetUpdater +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf + +/** + * Hand-written scriptable [FlexRepository] double for view-model tests. + * Defaults are inert; individual tests only configure the members they assert on. + */ +class ScriptableFlexRepository : FlexRepository { + + var recentWorkouts: List = emptyList() + var recentWorkoutsError: Exception? = null + + var workouts: List = emptyList() + var workoutsError: Exception? = null + + var workoutById: Workout? = null + + var allExercises: List = emptyList() + var exercisesByWorkoutId: Map> = emptyMap() + var exercisesError: Exception? = null + var setsByExerciseId: Map> = emptyMap() + + var profileInfo: ProfileInfo = ProfileInfo(null, null, false, 0, 0) + var profileInfoError: Exception? = null + + var workoutStatsResult: SingleWorkoutStats = SingleWorkoutStats(0, 0, 0.0) + var recentPRs: List = emptyList() + var recentPrsError: Exception? = null + var prsWithDetails: List = emptyList() + var prsError: Exception? = null + var muscleGroupProgress: List = emptyList() + var muscleRecovery: Map = emptyMap() + var volumeBalance: VolumeBalance = VolumeBalance(0f, 0f, 0f, 0f) + var consistencyDays: List = emptyList() + var periodComparison: PeriodComparison? = null + var weeklyGoalProgress: WeeklyGoalProgress = WeeklyGoalProgress(0, 5, "Behind") + var weekCalendar: List = emptyList() + var plannedForDay: (Long) -> List = { emptyList() } + var routines: List = emptyList() + var routineFolders: List = emptyList() + + var updateStatusResult: Result = Result.Success(Unit) + + /** When set, updateWorkoutStatus suspends until completed; the value is then returned. */ + var updateStatusGate: CompletableDeferred>? = null + + var rescheduleResult: Result = Result.Success(Unit) + var syncResult: Result = Result.Success(Unit) + + /** When set, syncAllData suspends until completed before returning [syncResult]. */ + var syncGate: CompletableDeferred? = null + + val statusUpdates = mutableListOf>() + val reschedules = mutableListOf>() + var syncCalls = 0 + private set + var clearCacheCalls = 0 + private set + + override fun invalidateApiService() {} + + override fun getWorkouts(): Flow> { + workoutsError?.let { error -> return flow { throw error } } + return flowOf(workouts) + } + + override fun getRecentWorkouts(limit: Int): Flow> { + recentWorkoutsError?.let { error -> return flow { throw error } } + return flowOf(recentWorkouts.take(limit)) + } + + override fun getAllExercises(): Flow> = flowOf(allExercises) + + override suspend fun getExerciseHistory(templateId: String): Result = + Result.Error(ApiError.Unknown("not faked")) + + override suspend fun getWorkoutById(workoutId: String): Workout? = workoutById + + override fun getWorkoutByIdFlow(workoutId: String): Flow = flowOf(workoutById) + + override fun getWorkoutCount(): Flow = flowOf(workouts.size) + + override suspend fun getExercisesByWorkoutId(workoutId: String): List { + exercisesError?.let { throw it } + return exercisesByWorkoutId[workoutId].orEmpty() + } + + override suspend fun getSetsByExerciseId(exerciseId: String): List = + setsByExerciseId[exerciseId].orEmpty() + + override suspend fun updateWorkoutStatus(workoutId: String, isCompleted: Boolean): Result { + statusUpdates += workoutId to isCompleted + updateStatusGate?.let { return it.await() } + return updateStatusResult + } + + override suspend fun rescheduleWorkout(workoutId: String, newStartTime: Long): Result { + reschedules += workoutId to newStartTime + return rescheduleResult + } + + override suspend fun calculateStats() = TestDefaults.emptyStats + + override suspend fun calculateWorkoutStats(workout: Workout): SingleWorkoutStats = + workoutStatsResult + + override fun getRecentPRs(limit: Int): Flow> { + recentPrsError?.let { error -> return flow { throw error } } + return flowOf(recentPRs) + } + + override suspend fun getPRsWithDetails(limit: Int): List { + prsError?.let { throw it } + return prsWithDetails + } + + override suspend fun getAllPRsWithDetails(): List { + prsError?.let { throw it } + return prsWithDetails + } + + override suspend fun getMuscleGroupProgress(weeks: Int): List = + muscleGroupProgress + + override suspend fun calculateVolumeTrend(weeks: Int) = VolumeTrend(0.0, 0.0, 0.0) + + override suspend fun getPeriodComparison(): PeriodComparison? = periodComparison + + override suspend fun getWeeklyVolumeData(weeks: Int) = emptyList() + + override suspend fun getDurationTrend(weeks: Int) = emptyList() + + override suspend fun getWeeklyGoalProgress(target: Int): WeeklyGoalProgress = weeklyGoalProgress + + override suspend fun getWeekCalendarData(): List = weekCalendar + + override suspend fun getPlannedWorkoutsForDay(timestamp: Long): List = + plannedForDay(timestamp) + + override suspend fun getVolumeBalance(weeks: Int): VolumeBalance = volumeBalance + + override suspend fun getWeeklyProgress(weeks: Int) = emptyList() + + override suspend fun getMemberSinceDate(): Long? = null + + override suspend fun calculateAccountAgeDays(): Int = 0 + + override suspend fun getProfileInfo(): ProfileInfo { + profileInfoError?.let { throw it } + return profileInfo + } + + override suspend fun getConsistencyData(days: Int): List = consistencyDays + + override suspend fun getMuscleRecoveryStatus(): Map = muscleRecovery + + override fun getRoutines(): Flow> = flowOf(routines) + + override suspend fun getRoutineById(routineId: String): Routine? = + routines.firstOrNull { it.id == routineId } + + override suspend fun getRoutineFolders(): List = routineFolders + + override suspend fun syncAllData(): Result { + syncCalls++ + syncGate?.await() + return syncResult + } + + override fun clearCache() { + clearCacheCalls++ + } + + override suspend fun syncWithCloud() {} +} + +/** Hand-written scriptable [RoutineRepository] double. */ +class ScriptableRoutineRepository : RoutineRepository { + var createResult: Result = Result.Success("routine-1") + val createdRequests = mutableListOf() + + override fun invalidateApiService() {} + override suspend fun syncRoutines(): Result = Result.Success(Unit) + override fun getRoutines(): Flow> = flowOf(emptyList()) + override suspend fun getRoutineById(routineId: String): Result = + Result.Error(ApiError.Unknown("not faked")) + override suspend fun getRoutineFolders(): Result> = + Result.Success(emptyList()) + override suspend fun createRoutine(request: CreateRoutineRequest): Result { + createdRequests += request + return createResult + } +} + +/** Hand-written scriptable [ExerciseRepository] double. */ +class ScriptableExerciseRepository : ExerciseRepository { + var templateNameMapping: Result> = Result.Success(emptyMap()) + + override fun invalidateApiService() {} + override suspend fun getExerciseTemplateMapping(): Result> = + templateNameMapping + override suspend fun getExerciseTemplateNameMapping(): Result> = + templateNameMapping + override suspend fun getMuscleGroupForExercise(exercise: Exercise): String? = null + override fun getExercisesByWorkoutId(workoutId: String): Flow> = + flowOf(emptyList()) + override fun getAllExercises(): Flow> = flowOf(emptyList()) + override suspend fun getExercisesByWorkoutIdSuspend(workoutId: String): List = + emptyList() + override suspend fun getExerciseById(exerciseId: String): Exercise? = null + override suspend fun getExerciseHistory(templateId: String): Result = + Result.Error(ApiError.Unknown("not faked")) +} + +/** Static [AiContextProvider] for use cases that need a context snapshot text. */ +class StaticAiContextProvider( + private val text: String = "System Context - Hevy Training Data" +) : AiContextProvider { + override suspend fun buildContext(userQuery: String?): HevyAiDataAccessor.ContextSnapshot = + HevyAiDataAccessor.ContextSnapshot( + text = text, + hasWorkoutData = true, + hasApiKey = true, + workoutCount = 1, + usesLiveExerciseHistory = false + ) +} + +/** + * NetworkMonitor is a final Android-bound class with no interface; tests stub its + * state flow via MockK and drive transitions through the returned StateFlow. + */ +fun networkMonitorStub( + initial: NetworkState = NetworkState.Unknown +): Pair> { + val flow = MutableStateFlow(initial) + val monitor = mockk { + every { networkState } returns flow + } + return monitor to flow +} + +/** WidgetUpdater touches Glance app widgets; tests only observe that it was invoked. */ +fun widgetUpdaterStub(): WidgetUpdater = mockk(relaxUnitFun = true) + +/** + * HealthConnectRepository is final and bound to the Health Connect SDK; tests stub + * it at the repository boundary. + */ +fun healthConnectRepositoryStub( + sdkAvailable: Boolean = false, + snapshot: HealthConnectSnapshot = HealthConnectSnapshot() +): HealthConnectRepository = mockk { + every { requiredPermissions } returns emptySet() + every { isSdkAvailable() } returns sdkAvailable + coEvery { readSnapshot() } returns snapshot + coEvery { writeWorkoutsToHealthConnect(any()) } returns 0 +} + +/** + * ApiKeyManager is final and backed by EncryptedSharedPreferences (no keystore under + * Robolectric); tests stub it while mirroring the real format-validation rule. + */ +class ScriptableApiKeyManager { + val keyFlow = MutableStateFlow(null) + val savedKeys = mutableListOf() + + val manager: ApiKeyManager = mockk { + every { apiKeyFlow } returns this@ScriptableApiKeyManager.keyFlow + every { isValidApiKeyFormat(any()) } answers { + val key: String = firstArg() + key.isNotBlank() && key.length >= 10 + } + coEvery { saveApiKey(any()) } coAnswers { + val key: String = firstArg() + savedKeys += key + keyFlow.value = key + } + } +} + +/** + * Resets every preference key view-model tests mutate. The preferences DataStore is a + * process-wide singleton, so without this, state leaks between Robolectric tests. + */ +suspend fun UserPreferencesManager.resetForTests() { + setWeeklyGoal(5) + setTheme("System") + setUnits("Imperial") + setViewOnlyMode(true) + setForceAiEnable(false) + setNotificationsEnabled(true) + setHealthConnectEnabled(false) + setHealthConnectWriteEnabled(false) +} + +/** Minimal FlexAIClient double for view models that only generate one-shot responses. */ +class OneShotFakeAiClient( + var available: Boolean = false, + var response: Result = Result.Success("AI says hi") +) : FlexAIClient { + val prompts = mutableListOf() + + override suspend fun isAvailable(): Boolean = available + override suspend fun getFeatureStatus(): AiFeatureStatus = AiFeatureStatus.Ready + override suspend fun prepareModel(): Result = Result.Success(Unit) + override suspend fun generateResponse(prompt: String, history: List>): Result { + prompts += prompt + return response + } + override suspend fun generateWorkoutPlan(prompt: String): Result { + prompts += prompt + return response + } + override fun generateResponseStream(prompt: String, history: List>) = flow { } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/DashboardViewModelTest.kt b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/DashboardViewModelTest.kt new file mode 100644 index 0000000..c7c6e08 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/DashboardViewModelTest.kt @@ -0,0 +1,340 @@ +package com.jdluu.flexinsight.ui.viewmodel + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.jdluu.flexinsight.TestApplication +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.core.network.NetworkMonitor +import com.jdluu.flexinsight.core.network.NetworkState +import com.jdluu.flexinsight.data.model.MuscleGroup +import com.jdluu.flexinsight.data.model.PlannedWorkout +import com.jdluu.flexinsight.data.model.ProfileInfo +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.data.preferences.SyncPreferencesManager +import com.jdluu.flexinsight.data.preferences.UserPreferencesManager +import com.jdluu.flexinsight.data.sync.SyncCoordinator +import com.jdluu.flexinsight.domain.usecase.CalculateTrainingLoadUseCase +import com.jdluu.flexinsight.domain.usecase.DetectDeloadUseCase +import com.jdluu.flexinsight.domain.usecase.GetMuscleRecoveryUseCase +import com.jdluu.flexinsight.domain.usecase.GetWeeklyProgressUseCase +import com.jdluu.flexinsight.domain.usecase.GetWorkoutStatsUseCase +import com.jdluu.flexinsight.fakes.FakeStatsRepository +import com.jdluu.flexinsight.fakes.FakeWorkoutRepository +import com.jdluu.flexinsight.fakes.OneShotFakeAiClient +import com.jdluu.flexinsight.fakes.ScriptableFlexRepository +import com.jdluu.flexinsight.fakes.TestDefaults +import com.jdluu.flexinsight.fakes.healthConnectRepositoryStub +import com.jdluu.flexinsight.fakes.networkMonitorStub +import com.jdluu.flexinsight.fakes.resetForTests +import com.jdluu.flexinsight.fakes.widgetUpdaterStub +import com.jdluu.flexinsight.ui.common.LoadingState +import com.jdluu.flexinsight.ui.common.UiError +import com.jdluu.flexinsight.widget.WidgetUpdater +import io.mockk.coVerify +import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = TestApplication::class) +class DashboardViewModelTest { + + private lateinit var repository: ScriptableFlexRepository + private lateinit var statsRepository: FakeStatsRepository + private lateinit var userPreferences: UserPreferencesManager + private lateinit var syncPreferences: SyncPreferencesManager + private lateinit var aiClient: OneShotFakeAiClient + private lateinit var networkMonitor: NetworkMonitor + private lateinit var networkFlow: MutableStateFlow + private lateinit var widgetUpdater: WidgetUpdater + + @Before + fun setUp() { + + repository = ScriptableFlexRepository() + statsRepository = FakeStatsRepository().apply { + statsToReturn = TestDefaults.emptyStats.copy( + totalWorkouts = 7, + currentStreak = 3, + longestStreak = 5 + ) + weeklyProgressToReturn = List(4) { week -> + WeeklyProgress( + weekStartDate = week.toLong(), + totalVolume = 1000.0, + workoutCount = 2, + averageVolume = 500.0 + ) + } + recoveryToReturn = mapOf(MuscleGroup.CHEST to 0.8f) + } + repository.profileInfo = ProfileInfo("Jay", 1000L, true, 7, 30) + repository.recentWorkouts = listOf(TestDefaults.workout("w1", startTime = 2000L)) + repository.plannedForDay = { listOf(plannedWorkout("Push Day")) } + + val context = ApplicationProvider.getApplicationContext() + userPreferences = UserPreferencesManager(context) + syncPreferences = SyncPreferencesManager(context) + kotlinx.coroutines.runBlocking { + userPreferences.resetForTests() + syncPreferences.clearAllForTests() + } + + aiClient = OneShotFakeAiClient(available = false) + val (monitor, flow) = networkMonitorStub(NetworkState.Unknown) + networkMonitor = monitor + networkFlow = flow + widgetUpdater = widgetUpdaterStub() + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun buildViewModel(): DashboardViewModel = DashboardViewModel( + repository = repository, + networkMonitor = networkMonitor, + userPreferencesManager = userPreferences, + aiClient = aiClient, + getWorkoutStatsUseCase = GetWorkoutStatsUseCase(statsRepository), + getWeeklyProgressUseCase = GetWeeklyProgressUseCase(statsRepository), + getMuscleRecoveryUseCase = GetMuscleRecoveryUseCase(statsRepository), + calculateTrainingLoadUseCase = CalculateTrainingLoadUseCase(repository, healthConnectRepositoryStub()), + detectDeloadUseCase = DetectDeloadUseCase(repository, healthConnectRepositoryStub()), + syncPreferencesManager = syncPreferences, + syncCoordinator = SyncCoordinator(FakeWorkoutRepository(), syncPreferences, healthConnectRepositoryStub()), + widgetUpdater = widgetUpdater + ) + + // region Initialization + + @Test + fun `init loads dashboard data and pushes widget metrics`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.uiState.test { + val state = nextMatching { it.loadingState == LoadingState.Success } + assertEquals("w1", state.latestWorkout?.id) + assertEquals("Jay", state.profileInfo?.displayName) + assertEquals(3, state.currentStreak) + assertEquals(7, state.workoutStats?.totalWorkouts) + assertEquals(4, state.weeklyProgress.size) + assertEquals(0.8f, state.muscleRecovery[MuscleGroup.CHEST]) + assertNotNull(state.trainingLoad) + assertFalse(state.deloadAlert?.shouldDeload ?: true) + assertTrue(aiClient.prompts.isEmpty()) + } + + coVerify(exactly = 1) { + widgetUpdater.updateFromDashboard(streak = 3, recoveryScore = 80, nextWorkoutLabel = "Push Day") + } + } + + @Test + fun `init surfaces error state when recent workouts cannot be read`() = runVmTest { + repository.recentWorkoutsError = IOException("offline") + val viewModel = buildViewModel() + testScheduler.advanceUntilIdle() + + viewModel.uiState.test { + val state = nextMatching { it.loadingState is LoadingState.Error } + assertTrue(state.error is UiError.Network) + assertEquals("Unable to connect to server", state.error?.message) + } + } + + @Test + fun `daily insight is generated when ai client is available`() = runVmTest { + aiClient.available = true + aiClient.response = Result.Success("Keep the streak alive!") + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.uiState.test { + val state = nextMatching { it.dailyInsight != null } + assertEquals("Keep the streak alive!", state.dailyInsight) + assertFalse(state.isGeneratingInsight) + } + assertEquals(1, aiClient.prompts.size) + assertTrue(aiClient.prompts.single().contains("3 day streak")) + } + + @Test + fun `network state changes are reflected in ui state`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + networkFlow.value = NetworkState.Unavailable + + viewModel.uiState.test { + val state = nextMatching { it.networkState is NetworkState.Unavailable } + assertEquals(NetworkState.Unavailable, state.networkState) + } + } + + @Test + fun `units preference changes are reflected in ui state`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + userPreferences.setUnits("Metric") + + awaitUntil { viewModel.uiState.value.units == "Metric" } + assertEquals("Metric", viewModel.uiState.value.units) + } + + // endregion + + // region Actions + + @Test + fun `refresh reloads dashboard data`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + repository.recentWorkouts = listOf(TestDefaults.workout("w2", startTime = 3000L)) + viewModel.refresh() + testScheduler.advanceUntilIdle() + + viewModel.uiState.test { + nextMatching { it.latestWorkout?.id == "w2" && it.loadingState == LoadingState.Success } + } + } + + @Test + fun `sync success settles state, notifies coordinator, and reloads`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + val gate = CompletableDeferred() + repository.syncGate = gate + + viewModel.sync() + + viewModel.uiState.test { + val syncing = nextMatching { it.isSyncing } + assertEquals(LoadingState.Loading, syncing.loadingState) + + gate.complete(Unit) + + val settled = nextMatching { !it.isSyncing && it.loadingState == LoadingState.Success } + assertEquals(LoadingState.Success, settled.loadingState) + assertFalse(settled.isSyncing) + cancelAndIgnoreRemainingEvents() + } + + assertEquals(1, repository.syncCalls) + awaitUntil { syncPreferences.lastSyncAtFlow.firstOrNull() != null } + } + + @Test + fun `sync failure surfaces banner error without notifying coordinator`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + repository.syncResult = Result.Error(ApiError.NetworkError.NoConnection) + + viewModel.sync() + + viewModel.uiState.test { + val settled = nextMatching { !it.isSyncing && it.loadingState is LoadingState.Error } + assertTrue(settled.error is UiError.Network) + } + assertEquals(1, repository.syncCalls) + + withContext(Dispatchers.IO) { delay(100) } + assertNull(syncPreferences.getLastSyncAt()) + } + + // endregion + + // region Helpers + + private fun plannedWorkout(name: String) = PlannedWorkout( + id = name, + name = name, + duration = 45L, + intensity = "High Intensity", + isCompleted = false, + routineId = "routine-1", + exerciseCount = 5 + ) + + /** Consumes emissions until one satisfies [predicate]; does not cancel the turbine. */ + private suspend fun TurbineTestContext.nextMatching(predicate: (T) -> Boolean): T { + var item = awaitItem() + while (!predicate(item)) { + item = awaitItem() + } + return item + } + + /** Resolves the init delay and waits for the initial load to settle. */ + private suspend fun awaitInitialLoad( + scheduler: kotlinx.coroutines.test.TestCoroutineScheduler, + viewModel: DashboardViewModel + ) { + scheduler.advanceUntilIdle() + awaitUntil("initial load") { + viewModel.uiState.value.loadingState != LoadingState.Loading + } + } + + /** Polls with real time so DataStore-backed flows (real IO threads) can progress. */ + private suspend fun awaitUntil( + label: String = "condition", + timeoutMs: Long = 10_000, + condition: suspend () -> Boolean + ) { + val start = System.currentTimeMillis() + while (!condition()) { + if (System.currentTimeMillis() - start > timeoutMs) { + throw AssertionError("$label not met within ${timeoutMs}ms") + } + withContext(Dispatchers.IO) { delay(10) } + } + } + + // endregion + + /** + * Runs [block] with Dispatchers.Main bound to an [UnconfinedTestDispatcher] sharing + * this test's scheduler, so view-model coroutines (including init delays) resolve + * on the same virtual clock the assertions observe. + */ + private fun runVmTest(block: suspend kotlinx.coroutines.test.TestScope.() -> Unit) = + kotlinx.coroutines.test.runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + Dispatchers.resetMain() + } + } + +} diff --git a/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/HistoryViewModelTest.kt b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/HistoryViewModelTest.kt new file mode 100644 index 0000000..c85b3c5 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/HistoryViewModelTest.kt @@ -0,0 +1,337 @@ +package com.jdluu.flexinsight.ui.viewmodel + +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.jdluu.flexinsight.TestApplication +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.model.PRDetails +import com.jdluu.flexinsight.data.model.PeriodComparison +import com.jdluu.flexinsight.domain.usecase.CompareRoutineSessionsUseCase +import com.jdluu.flexinsight.domain.usecase.GetPRDetailsUseCase +import com.jdluu.flexinsight.domain.usecase.GetWeeklyProgressUseCase +import com.jdluu.flexinsight.domain.usecase.GetWorkoutStatsUseCase +import com.jdluu.flexinsight.fakes.FakeStatsRepository +import com.jdluu.flexinsight.fakes.FakeWorkoutRepository +import com.jdluu.flexinsight.fakes.OneShotFakeAiClient +import com.jdluu.flexinsight.fakes.ScriptableFlexRepository +import com.jdluu.flexinsight.fakes.StaticAiContextProvider +import com.jdluu.flexinsight.fakes.resetForTests +import com.jdluu.flexinsight.fakes.TestDefaults +import com.jdluu.flexinsight.ui.common.LoadingState +import com.jdluu.flexinsight.ui.common.UiError +import androidx.test.core.app.ApplicationProvider +import android.content.Context +import com.jdluu.flexinsight.data.preferences.UserPreferencesManager +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = TestApplication::class) +class HistoryViewModelTest { + + private lateinit var repository: ScriptableFlexRepository + private lateinit var statsRepository: FakeStatsRepository + private lateinit var workoutRepository: FakeWorkoutRepository + private lateinit var userPreferences: UserPreferencesManager + private lateinit var aiClient: OneShotFakeAiClient + + private val now = System.currentTimeMillis() + private val dayMs = 24L * 60 * 60 * 1000 + + @Before + fun setUp() { + + repository = ScriptableFlexRepository().apply { + profileInfo = com.jdluu.flexinsight.data.model.ProfileInfo(null, null, false, 7, 30) + workouts = listOf( + TestDefaults.workout("w-new", name = "Push Day", startTime = now - dayMs, routineId = "r1"), + TestDefaults.workout("w-old", name = "Push Day", startTime = now - 8 * dayMs, routineId = "r1"), + TestDefaults.workout("w-ancient", name = "Legs", startTime = now - 40 * dayMs) + ) + periodComparison = PeriodComparison( + currentPeriodLabel = "This Month", + previousPeriodLabel = "Last Month", + totalVolumeCurrent = 12000.0, + totalVolumePrevious = 10000.0, + totalWorkoutsCurrent = 8, + totalWorkoutsPrevious = 7, + avgDurationCurrent = 45L, + avgDurationPrevious = 50L + ) + } + statsRepository = FakeStatsRepository().apply { + statsToReturn = TestDefaults.emptyStats.copy(totalVolume = 120000.0, currentStreak = 4) + prsToReturn = listOf( + PRDetails("Bench Press", now, "Chest", 100.0, "w-new", "s1") + ) + } + + // Two sessions of the same routine so comparisons have data to work with. + val recentExercise = TestDefaults.exercise("e-new", "w-new", name = "Bench Press") + val priorExercise = TestDefaults.exercise("e-old", "w-old", name = "Bench Press") + workoutRepository = FakeWorkoutRepository().apply { + workoutsFlow.value = listOf( + TestDefaults.workout("w-new", startTime = now - dayMs, routineId = "r1"), + TestDefaults.workout("w-old", startTime = now - 8 * dayMs, routineId = "r1"), + TestDefaults.workout("w2-new", startTime = now - 2 * dayMs, routineId = "r2"), + TestDefaults.workout("w2-old", startTime = now - 9 * dayMs, routineId = "r2") + ) + exercisesByWorkout["w-new"] = listOf(recentExercise) + exercisesByWorkout["w-old"] = listOf(priorExercise) + setsByExercise["e-new"] = listOf(TestDefaults.set("s1", "e-new", weight = 120.0, reps = 5)) + setsByExercise["e-old"] = listOf(TestDefaults.set("s2", "e-old", weight = 100.0, reps = 5)) + exercisesByWorkout["w2-new"] = listOf(TestDefaults.exercise("e2-new", "w2-new", name = "Barbell Row")) + exercisesByWorkout["w2-old"] = listOf(TestDefaults.exercise("e2-old", "w2-old", name = "Barbell Row")) + setsByExercise["e2-new"] = listOf(TestDefaults.set("s3", "e2-new", weight = 80.0, reps = 8)) + setsByExercise["e2-old"] = listOf(TestDefaults.set("s4", "e2-old", weight = 70.0, reps = 8)) + } + + aiClient = OneShotFakeAiClient(available = false) + userPreferences = UserPreferencesManager(ApplicationProvider.getApplicationContext()) + kotlinx.coroutines.runBlocking { userPreferences.resetForTests() } + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun buildViewModel(): HistoryViewModel = HistoryViewModel( + repository = repository, + userPreferencesManager = userPreferences, + aiClient = aiClient, + getWorkoutStatsUseCase = GetWorkoutStatsUseCase(statsRepository), + getPRDetailsUseCase = GetPRDetailsUseCase(statsRepository), + getWeeklyProgressUseCase = GetWeeklyProgressUseCase(statsRepository), + compareRoutineSessionsUseCase = CompareRoutineSessionsUseCase(workoutRepository) + ) + + // region Initialization + + @Test + fun `init loads history with stats, PRs, comparison data and routine comparison`() = runVmTest { + val viewModel = buildViewModel() + + viewModel.uiState.test { + val state = nextMatching { it.loadingState == LoadingState.Success } + assertEquals(3, state.allWorkouts.size) + assertEquals(3, state.workouts.size) + assertEquals(7, state.workoutCount) + assertEquals(120000.0, state.workoutStats?.totalVolume) + assertEquals(1, state.prsWithDetails.size) + assertEquals("This Month", state.compareData?.currentPeriodLabel) + assertEquals(8, state.compareData?.totalWorkoutsCurrent) + assertNotNull(state.routineComparison) + assertTrue(state.routineComparison!!.summary.contains("improved")) + assertNull(state.error) + assertFalse(state.isGeneratingTrend) + cancelAndIgnoreRemainingEvents() + } + assertTrue(aiClient.prompts.isEmpty()) + } + + @Test + fun `init falls back to defaults when optional data fails to load`() = runVmTest { + repository.recentPrsError = RuntimeException("recent pr boom") + val viewModel = buildViewModel() + + viewModel.uiState.test { + val state = nextMatching { it.loadingState == LoadingState.Success } + assertEquals(LoadingState.Success, state.loadingState) + assertEquals(4, state.workoutStats?.currentStreak) + assertEquals(1, state.prsWithDetails.size) + assertTrue(state.recentPRs.isEmpty()) + assertNull(state.error) + } + } + + @Test + fun `init surfaces error state when the workout stream fails`() = runVmTest { + repository.workoutsError = IOException("database gone") + val viewModel = buildViewModel() + + viewModel.uiState.test { + val state = nextMatching { it.loadingState is LoadingState.Error } + assertTrue(state.error is UiError.Network) + assertEquals("Unable to connect to server", state.error?.message) + assertTrue(state.workouts.isEmpty()) + } + } + + @Test + fun `init generates trend analysis when ai client is available`() = runVmTest { + aiClient.available = true + aiClient.response = Result.Success("You are consistent!") + val viewModel = buildViewModel() + + viewModel.uiState.test { + val state = nextMatching { it.aiTrendAnalysis != null } + assertEquals("You are consistent!", state.aiTrendAnalysis) + assertFalse(state.isGeneratingTrend) + cancelAndIgnoreRemainingEvents() + } + assertEquals(1, aiClient.prompts.size) + val prompt = aiClient.prompts.single() + assertTrue(prompt.contains("Total workouts: 7")) + assertTrue(prompt.contains("Streak: 4 days")) + } + + // endregion + + // region Filters + + @Test + fun `date filter narrows visible workouts`() = runVmTest { + val viewModel = buildViewModel() + awaitSuccess(viewModel) + + viewModel.setDateFilter("This Week") + + viewModel.uiState.test { + val state = nextMatching { it.dateFilter == "This Week" } + assertEquals(listOf("w-new"), state.workouts.map { it.id }) + assertEquals(3, state.allWorkouts.size) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `all time filter restores every workout`() = runVmTest { + val viewModel = buildViewModel() + awaitSuccess(viewModel) + viewModel.setDateFilter("This Week") + + viewModel.setDateFilter("All Time") + + viewModel.uiState.test { + val state = nextMatching { it.dateFilter == "All Time" } + assertEquals(3, state.workouts.size) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `muscle group filter matches workout names case-insensitively`() = runVmTest { + val viewModel = buildViewModel() + awaitSuccess(viewModel) + + viewModel.setMuscleGroupFilter("legs") + + viewModel.uiState.test { + val state = nextMatching { it.muscleGroupFilter == "legs" } + assertEquals(listOf("w-ancient"), state.workouts.map { it.id }) + cancelAndIgnoreRemainingEvents() + } + + viewModel.setMuscleGroupFilter(null) + + viewModel.uiState.test { + val state = nextMatching { it.muscleGroupFilter == null } + assertEquals(3, state.workouts.size) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `combined date and muscle group filters intersect`() = runVmTest { + val viewModel = buildViewModel() + awaitSuccess(viewModel) + + viewModel.setMuscleGroupFilter("Push") + viewModel.setDateFilter("This Week") + + viewModel.uiState.test { + val state = nextMatching { it.dateFilter == "This Week" } + assertEquals(listOf("w-new"), state.workouts.map { it.id }) + assertEquals("Push", state.muscleGroupFilter) + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + // region Actions + + @Test + fun `refresh picks up newly synced workouts`() = runVmTest { + val viewModel = buildViewModel() + awaitSuccess(viewModel) + + repository.workouts = repository.workouts + TestDefaults.workout("w-brand-new", startTime = now) + viewModel.refresh() + + viewModel.uiState.test { + val state = nextMatching { it.workouts.any { w -> w.id == "w-brand-new" } } + assertEquals(4, state.allWorkouts.size) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadRoutineComparison builds comparison for requested routine`() = runVmTest { + val viewModel = buildViewModel() + awaitSuccess(viewModel) + + viewModel.loadRoutineComparison("r2", "Pull Day") + + viewModel.uiState.test { + val state = nextMatching { it.routineComparison?.routineName == "Pull Day" } + assertEquals("Pull Day", state.routineComparison?.routineName) + assertTrue(state.routineComparison!!.improvements.isNotEmpty()) + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + private suspend fun awaitSuccess(viewModel: HistoryViewModel) { + viewModel.uiState.test { + nextMatching { it.loadingState == LoadingState.Success } + cancelAndIgnoreRemainingEvents() + } + } + + /** Consumes emissions until one satisfies [predicate], then discards the rest. */ + private suspend fun TurbineTestContext.nextMatching(predicate: (T) -> Boolean): T { + var item = awaitItem() + while (!predicate(item)) { + item = awaitItem() + } + cancelAndIgnoreRemainingEvents() + return item + } + + /** + * Runs [block] with Dispatchers.Main bound to an [UnconfinedTestDispatcher] sharing + * this test's scheduler, so view-model coroutines (including init delays) resolve + * on the same virtual clock the assertions observe. + */ + private fun runVmTest(block: suspend kotlinx.coroutines.test.TestScope.() -> Unit) = + kotlinx.coroutines.test.runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + Dispatchers.resetMain() + } + } + +} diff --git a/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/PRListViewModelTest.kt b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/PRListViewModelTest.kt new file mode 100644 index 0000000..eb5ddce --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/PRListViewModelTest.kt @@ -0,0 +1,147 @@ +package com.jdluu.flexinsight.ui.viewmodel + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.jdluu.flexinsight.TestApplication +import com.jdluu.flexinsight.data.preferences.UserPreferencesManager +import com.jdluu.flexinsight.fakes.ScriptableFlexRepository +import com.jdluu.flexinsight.fakes.resetForTests +import com.jdluu.flexinsight.ui.common.LoadingState +import com.jdluu.flexinsight.ui.common.UiError +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = TestApplication::class) +class PRListViewModelTest { + + private lateinit var repository: ScriptableFlexRepository + private lateinit var userPreferences: UserPreferencesManager + + @Before + fun setUp() { + + repository = ScriptableFlexRepository() + repository.prsWithDetails = listOf( + com.jdluu.flexinsight.data.model.PRDetails("Bench Press", 1000L, "Chest", 100.0, "w1", "s1"), + com.jdluu.flexinsight.data.model.PRDetails("Squat", 2000L, "Legs", 140.0, "w2", "s2") + ) + userPreferences = UserPreferencesManager(ApplicationProvider.getApplicationContext()) + kotlinx.coroutines.runBlocking { userPreferences.resetForTests() } + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun buildViewModel(): PRListViewModel = + PRListViewModel(repository, userPreferences) + + @Test + fun `init loads all personal records`() = runVmTest { + val viewModel = buildViewModel() + + viewModel.uiState.test { + val state = nextMatching { it.loadingState == LoadingState.Success } + assertEquals(2, state.prs.size) + assertEquals("Bench Press", state.prs.first().exerciseName) + assertNull(state.error) + assertEquals("Imperial", state.units) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `init surfaces error state when records cannot be read`() = runVmTest { + repository.prsError = IOException("pr boom") + val viewModel = buildViewModel() + + viewModel.uiState.test { + val state = nextMatching { it.loadingState is LoadingState.Error } + assertTrue(state.error is UiError.Network) + assertEquals("Unable to connect to server", state.error?.message) + assertTrue(state.prs.isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `units preference changes are reflected`() = runVmTest { + val viewModel = buildViewModel() + viewModel.uiState.test { + nextMatching { it.loadingState == LoadingState.Success } + } + + userPreferences.setUnits("Metric") + + viewModel.uiState.test { + nextMatching { it.units == "Metric" } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadPRs refresh picks up new records`() = runVmTest { + val viewModel = buildViewModel() + viewModel.uiState.test { + nextMatching { it.loadingState == LoadingState.Success } + cancelAndIgnoreRemainingEvents() + } + + repository.prsWithDetails += com.jdluu.flexinsight.data.model.PRDetails( + "Deadlift", 3000L, "Back", 180.0, "w3", "s3" + ) + viewModel.loadPRs() + + viewModel.uiState.test { + val state = nextMatching { it.prs.size == 3 && it.loadingState == LoadingState.Success } + assertEquals("Deadlift", state.prs.last().exerciseName) + cancelAndIgnoreRemainingEvents() + } + } + + /** Consumes emissions until one satisfies [predicate], then discards the rest. */ + private suspend fun TurbineTestContext.nextMatching(predicate: (T) -> Boolean): T { + var item = awaitItem() + while (!predicate(item)) { + item = awaitItem() + } + cancelAndIgnoreRemainingEvents() + return item + } + + /** + * Runs [block] with Dispatchers.Main bound to an [UnconfinedTestDispatcher] sharing + * this test's scheduler, so view-model coroutines (including init delays) resolve + * on the same virtual clock the assertions observe. + */ + private fun runVmTest(block: suspend kotlinx.coroutines.test.TestScope.() -> Unit) = + kotlinx.coroutines.test.runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + Dispatchers.resetMain() + } + } + +} diff --git a/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/PlannerViewModelTest.kt b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/PlannerViewModelTest.kt new file mode 100644 index 0000000..c500acb --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/PlannerViewModelTest.kt @@ -0,0 +1,476 @@ +package com.jdluu.flexinsight.ui.viewmodel + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.jdluu.flexinsight.TestApplication +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.model.DayInfo +import com.jdluu.flexinsight.data.model.PlannedWorkout +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.preferences.UserPreferencesManager +import com.jdluu.flexinsight.fakes.OneShotFakeAiClient +import com.jdluu.flexinsight.fakes.ScriptableExerciseRepository +import com.jdluu.flexinsight.fakes.ScriptableFlexRepository +import com.jdluu.flexinsight.fakes.ScriptableRoutineRepository +import com.jdluu.flexinsight.fakes.resetForTests +import com.jdluu.flexinsight.ui.common.LoadingState +import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import java.time.ZoneId + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = TestApplication::class) +class PlannerViewModelTest { + + private lateinit var repository: ScriptableFlexRepository + private lateinit var routineRepository: ScriptableRoutineRepository + private lateinit var exerciseRepository: ScriptableExerciseRepository + private lateinit var userPreferences: UserPreferencesManager + private lateinit var aiClient: OneShotFakeAiClient + + /** Backing store behind repository.plannedForDay so reloads observe mutations. */ + private val plannedByDay = mutableMapOf>() + + private val todayStart: Long = + LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + private val dayMs = 24L * 60 * 60 * 1000 + + @Before + fun setUp() { + + repository = ScriptableFlexRepository() + repository.reschedules.clear() + routineRepository = ScriptableRoutineRepository() + exerciseRepository = ScriptableExerciseRepository() + aiClient = OneShotFakeAiClient(available = false) + userPreferences = UserPreferencesManager(ApplicationProvider.getApplicationContext()) + kotlinx.coroutines.runBlocking { userPreferences.resetForTests() } + + repository.weekCalendar = listOf( + DayInfo("Mon", 1, todayStart - dayMs, hasWorkout = true, isCompleted = true, workoutCount = 1), + DayInfo("Tue", 2, todayStart, hasWorkout = true, isCompleted = false, workoutCount = 2), + DayInfo("Wed", 3, todayStart + dayMs, hasWorkout = false, isCompleted = false, workoutCount = 0) + ) + repository.plannedForDay = { timestamp -> plannedByDay[timestamp].orEmpty() } + repository.volumeBalance = VolumeBalance(0.5f, 0.25f, 0.25f, 0f) + plannedByDay[todayStart - dayMs] = listOf(plannedWorkout("pw-yesterday", "Yesterday Session")) + plannedByDay[todayStart] = listOf(plannedWorkout("pw-today", "Today Session")) + plannedByDay[todayStart + dayMs] = listOf(plannedWorkout("pw-tomorrow", "Tomorrow Session")) + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun buildViewModel(): PlannerViewModel = PlannerViewModel( + repository = repository, + routineRepository = routineRepository, + exerciseRepository = exerciseRepository, + aiClient = aiClient, + userPreferencesManager = userPreferences + ) + + // region Initialization + + @Test + fun `init loads planner data and selects today`() = runVmTest { + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + + viewModel.uiState.test { + val state = nextMatching { it.loadingState == LoadingState.Success } + assertEquals(3, state.weekCalendarData.size) + assertEquals(1, state.selectedDayIndex) + assertEquals(listOf("pw-today"), state.selectedDayWorkouts.map { it.id }) + assertEquals(VolumeBalance(0.5f, 0.25f, 0.25f, 0f), state.volumeBalance) + assertTrue(state.viewOnlyMode) + assertFalse(state.hevyEditingEnabled) + assertNull(state.error) + assertNull(state.editBlockedMessage) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `init tolerates failures in per-day planning data`() = runVmTest { + repository.plannedForDay = { throw java.io.IOException("planner boom") } + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + + viewModel.uiState.test { + val state = nextMatching { it.loadingState == LoadingState.Success } + assertTrue(state.weekCalendarData.isNotEmpty()) + assertTrue(state.selectedDayWorkouts.isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + // region Day selection + + @Test + fun `selectDay loads workouts for the chosen day`() = runVmTest { + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + + viewModel.selectDay(0) + + viewModel.uiState.test { + val state = nextMatching { it.selectedDayIndex == 0 } + assertEquals(listOf("pw-yesterday"), state.selectedDayWorkouts.map { it.id }) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `selectDay ignores out of range indexes`() = runVmTest { + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + + viewModel.selectDay(42) + + assertEquals(1, viewModel.uiState.value.selectedDayIndex) + } + + // endregion + + // region Workout completion (view-only gating) + + @Test + fun `markWorkoutAsComplete is blocked in view-only mode with a message`() = runVmTest { + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + + viewModel.markWorkoutAsComplete("pw-today", true) + + viewModel.uiState.test { + val state = nextMatching { it.editBlockedMessage != null } + assertTrue(state.editBlockedMessage!!.contains("Edits are disabled")) + cancelAndIgnoreRemainingEvents() + } + assertTrue(repository.statusUpdates.isEmpty()) + assertFalse(viewModel.uiState.value.selectedDayWorkouts.first().isCompleted) + + viewModel.clearEditBlockedMessage() + assertNull(viewModel.uiState.value.editBlockedMessage) + } + + @Test + fun `markWorkoutAsComplete optimistically flips state then reloads on success`() = runVmTest { + enableEditing() + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = false) + val gate = CompletableDeferred>() + repository.updateStatusGate = gate + + viewModel.markWorkoutAsComplete("pw-today", true) + + viewModel.uiState.test { + nextMatching { + it.selectedDayWorkouts.firstOrNull()?.id == "pw-today" && + it.selectedDayWorkouts.first().isCompleted + } + + gate.complete(Result.Success(Unit)) + + nextMatching { + !it.isLoading && + it.selectedDayWorkouts.firstOrNull()?.id == "pw-today" && + !it.selectedDayWorkouts.first().isCompleted + } + cancelAndIgnoreRemainingEvents() + } + assertEquals("pw-today" to true, repository.statusUpdates.single()) + } + + @Test + fun `markWorkoutAsComplete reverts optimistic update and reports error on failure`() = runVmTest { + enableEditing() + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = false) + val gate = CompletableDeferred>() + repository.updateStatusGate = gate + + viewModel.markWorkoutAsComplete("pw-today", true) + + viewModel.uiState.test { + nextMatching { it.selectedDayWorkouts.first().isCompleted } + + gate.complete(Result.Error(ApiError.ClientError.BadRequest)) + + val reverted = nextMatching { + !it.selectedDayWorkouts.first().isCompleted && it.error != null + } + assertNotNull(reverted.error) + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + // region Rescheduling + + @Test + fun `rescheduleWorkout persists and reloads on success`() = runVmTest { + enableEditing() + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = false) + + viewModel.rescheduleWorkout("pw-today", todayStart + dayMs) + + assertEquals("pw-today" to todayStart + dayMs, repository.reschedules.single()) + awaitReady(testScheduler, viewModel, expectedViewOnly = false) // reload settled back at Success + } + + @Test + fun `rescheduleWorkout reports error on failure`() = runVmTest { + enableEditing() + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = false) + repository.rescheduleResult = Result.Error(ApiError.ClientError.BadRequest) + + viewModel.rescheduleWorkout("pw-today", todayStart + dayMs) + + viewModel.uiState.test { + assertNotNull(nextMatching { it.error != null }.error) + cancelAndIgnoreRemainingEvents() + } + assertEquals(1, repository.reschedules.size) + } + + @Test + fun `rescheduleWorkout is blocked in view-only mode`() = runVmTest { + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + + viewModel.rescheduleWorkout("pw-today", todayStart + dayMs) + + assertTrue(repository.reschedules.isEmpty()) + assertNotNull(viewModel.uiState.value.editBlockedMessage) + } + + // endregion + + // region AI plan generation and push-to-Hevy + + @Test + fun `generateAIWorkout explains unavailable ai without calling model`() = runVmTest { + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + + viewModel.generateAIWorkout() + + viewModel.uiState.test { + val state = nextMatching { it.aiPlan != null } + assertEquals("AI features are not available on this device.", state.aiPlan) + assertFalse(state.isGeneratingPlan) + cancelAndIgnoreRemainingEvents() + } + assertTrue(aiClient.prompts.isEmpty()) + } + + @Test + fun `generateAIWorkout includes volume balance focus in prompt and shows plan`() = runVmTest { + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + aiClient.available = true + aiClient.response = Result.Success("Push plan: Bench Press - 3x8") + + viewModel.generateAIWorkout() + + viewModel.uiState.test { + val state = nextMatching { it.aiPlan != null } + assertFalse(state.isGeneratingPlan) + assertEquals("Push plan: Bench Press - 3x8", state.aiPlan) + cancelAndIgnoreRemainingEvents() + } + assertTrue(aiClient.prompts.single().contains("Push=50%")) + } + + @Test + fun `pushRoutineToHevy does nothing without an ai plan`() = runVmTest { + enableEditing() + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = false) + + viewModel.pushRoutineToHevy("My Plan") + + assertTrue(routineRepository.createdRequests.isEmpty()) + assertEquals(SaveToHevyStatus.Idle, viewModel.uiState.value.saveToHevyStatus) + } + + @Test + fun `pushRoutineToHevy is blocked in view-only mode even with a plan`() = runVmTest { + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = true) + generatePlan(viewModel) + + viewModel.pushRoutineToHevy("My Plan") + + assertTrue(routineRepository.createdRequests.isEmpty()) + assertNotNull(viewModel.uiState.value.editBlockedMessage) + } + + @Test + fun `pushRoutineToHevy parses matched exercises and saves routine`() = runVmTest { + enableEditing() + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = false) + aiClient.response = Result.Success("Push plan:\nBench Press - 3x8\nMystery Machine - 2x10") + exerciseRepository.templateNameMapping = + Result.Success(mapOf("tpl-bench" to "Bench Press")) + routineRepository.createResult = Result.Success("routine-9") + generatePlan(viewModel) + + viewModel.pushRoutineToHevy("My Plan") + + viewModel.uiState.test { + val state = nextMatching { it.saveToHevyStatus is SaveToHevyStatus.Success } + val status = state.saveToHevyStatus as SaveToHevyStatus.Success + assertEquals("routine-9", status.routineId) + assertEquals(1, status.matchedCount) + assertEquals(listOf("Mystery Machine"), status.unmatchedNames) + assertFalse(status.usedPlaceholder) + cancelAndIgnoreRemainingEvents() + } + val request = routineRepository.createdRequests.single() + assertEquals("My Plan", request.routine.title) + assertEquals("tpl-bench", request.routine.exercises.single().exerciseTemplateId) + assertEquals(3, request.routine.exercises.single().sets.size) + } + + @Test + fun `pushRoutineToHevy surfaces save error and clearSaveStatus resets it`() = runVmTest { + enableEditing() + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = false) + aiClient.response = Result.Success("Bench Press - 3x8") + exerciseRepository.templateNameMapping = + Result.Success(mapOf("tpl-bench" to "Bench Press")) + routineRepository.createResult = Result.Error(ApiError.ClientError.BadRequest) + generatePlan(viewModel) + + viewModel.pushRoutineToHevy("My Plan") + + viewModel.uiState.test { + val state = nextMatching { it.saveToHevyStatus is SaveToHevyStatus.Error } + assertTrue((state.saveToHevyStatus as SaveToHevyStatus.Error).message.isNotEmpty()) + cancelAndIgnoreRemainingEvents() + } + + viewModel.clearSaveStatus() + assertEquals(SaveToHevyStatus.Idle, viewModel.uiState.value.saveToHevyStatus) + } + + @Test + fun `clearAIPlan resets plan and save status`() = runVmTest { + enableEditing() + val viewModel = buildViewModel() + awaitReady(testScheduler, viewModel, expectedViewOnly = false) + generatePlan(viewModel) + + viewModel.clearAIPlan() + + assertNull(viewModel.uiState.value.aiPlan) + assertEquals(SaveToHevyStatus.Idle, viewModel.uiState.value.saveToHevyStatus) + } + + // endregion + + private fun plannedWorkout(id: String, name: String) = PlannedWorkout( + id = id, + name = name, + duration = 60L, + intensity = "High Intensity", + isCompleted = false, + routineId = "routine-1", + exerciseCount = 4 + ) + + /** Persists view-only off before the VM collects the preference flow. */ + private suspend fun enableEditing() { + userPreferences.setViewOnlyMode(false) + } + + /** + * Resolves the init delay(100), then polls until the load reached Success and the + * view-only preference has propagated (DataStore emits via real IO threads). + */ + private suspend fun awaitReady( + scheduler: kotlinx.coroutines.test.TestCoroutineScheduler, + viewModel: PlannerViewModel, + expectedViewOnly: Boolean + ) { + scheduler.advanceUntilIdle() + val start = System.currentTimeMillis() + while (!(viewModel.uiState.value.loadingState == LoadingState.Success && + viewModel.uiState.value.viewOnlyMode == expectedViewOnly) + ) { + if (System.currentTimeMillis() - start > 10_000) { + throw AssertionError("Planner never became ready") + } + kotlinx.coroutines.withContext(Dispatchers.IO) { kotlinx.coroutines.delay(10) } + } + } + + private suspend fun generatePlan(viewModel: PlannerViewModel) { + aiClient.available = true + viewModel.generateAIWorkout() + val start = System.currentTimeMillis() + while (viewModel.uiState.value.aiPlan == null || viewModel.uiState.value.isGeneratingPlan) { + if (System.currentTimeMillis() - start > 10_000) { + throw AssertionError("AI plan never settled") + } + kotlinx.coroutines.withContext(Dispatchers.IO) { kotlinx.coroutines.delay(10) } + } + } + + /** Consumes emissions until one satisfies [predicate]; does not cancel the turbine. */ + private suspend fun TurbineTestContext.nextMatching(predicate: (T) -> Boolean): T { + var item = awaitItem() + while (!predicate(item)) { + item = awaitItem() + } + return item + } + + /** + * Runs [block] with Dispatchers.Main bound to an [UnconfinedTestDispatcher] sharing + * this test's scheduler, so view-model coroutines (including init delays) resolve + * on the same virtual clock the assertions observe. + */ + private fun runVmTest(block: suspend kotlinx.coroutines.test.TestScope.() -> Unit) = + kotlinx.coroutines.test.runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + Dispatchers.resetMain() + } + } + +} diff --git a/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/SettingsViewModelTest.kt new file mode 100644 index 0000000..ba9bc0d --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/SettingsViewModelTest.kt @@ -0,0 +1,369 @@ +package com.jdluu.flexinsight.ui.viewmodel + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.jdluu.flexinsight.TestApplication +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.core.network.NetworkState +import com.jdluu.flexinsight.data.model.ProfileInfo +import com.jdluu.flexinsight.data.preferences.SyncPreferencesManager +import com.jdluu.flexinsight.data.preferences.UserPreferencesManager +import com.jdluu.flexinsight.data.sync.SyncCoordinator +import com.jdluu.flexinsight.domain.ai.AiContextProvider +import com.jdluu.flexinsight.domain.usecase.BuildAiContextUseCase +import com.jdluu.flexinsight.domain.usecase.CalculateTrainingLoadUseCase +import com.jdluu.flexinsight.domain.usecase.ExportCoachReportUseCase +import com.jdluu.flexinsight.fakes.FakeWorkoutRepository +import com.jdluu.flexinsight.fakes.OneShotFakeAiClient +import com.jdluu.flexinsight.fakes.ScriptableApiKeyManager +import com.jdluu.flexinsight.fakes.ScriptableFlexRepository +import com.jdluu.flexinsight.fakes.healthConnectRepositoryStub +import com.jdluu.flexinsight.fakes.networkMonitorStub +import com.jdluu.flexinsight.fakes.resetForTests +import com.jdluu.flexinsight.ui.common.LoadingState +import com.jdluu.flexinsight.ui.common.UiError +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = TestApplication::class) +class SettingsViewModelTest { + + private lateinit var repository: ScriptableFlexRepository + private lateinit var userPreferences: UserPreferencesManager + private lateinit var syncPreferences: SyncPreferencesManager + private lateinit var apiKeyManager: ScriptableApiKeyManager + + @Before + fun setUp() { + + repository = ScriptableFlexRepository() + repository.profileInfo = ProfileInfo(null, 1000L, false, 7, 30) + + val context = ApplicationProvider.getApplicationContext() + userPreferences = UserPreferencesManager(context) + syncPreferences = SyncPreferencesManager(context) + apiKeyManager = ScriptableApiKeyManager() + + kotlinx.coroutines.runBlocking { + userPreferences.resetForTests() + syncPreferences.clearAllForTests() + } + + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun buildViewModel(): SettingsViewModel = SettingsViewModel( + repository = repository, + userPreferencesManager = userPreferences, + apiKeyManager = apiKeyManager.manager, + networkMonitor = networkMonitorStub(NetworkState.Available).first, + healthConnectRepository = healthConnectRepositoryStub(sdkAvailable = false), + syncCoordinator = SyncCoordinator(FakeWorkoutRepository(), syncPreferences, healthConnectRepositoryStub()), + exportCoachReportUseCase = ExportCoachReportUseCase( + flexRepository = repository, + buildAiContextUseCase = BuildAiContextUseCase(StaticProvider), + aiClient = OneShotFakeAiClient(), + calculateTrainingLoadUseCase = CalculateTrainingLoadUseCase( + repository, + healthConnectRepositoryStub() + ) + ) + ) + + // region Initialization + + @Test + fun `init loads preference defaults and merges display name into profile`() = runVmTest { + kotlinx.coroutines.runBlocking { userPreferences.setDisplayName("Jay") } + + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.uiState.test { + val state = nextMatching { it.loadingState == LoadingState.Success } + assertEquals(5, state.weeklyGoal) + assertEquals("System", state.theme) + assertEquals("Imperial", state.units) + assertTrue(state.notificationsEnabled) + assertTrue(state.viewOnlyMode) + assertFalse(state.forceAiEnable) + assertFalse(state.healthConnectAvailable) + assertFalse(state.healthConnectEnabled) + assertFalse(state.healthConnectWriteEnabled) + assertEquals(NetworkState.Available, state.networkState) + assertEquals("Jay", state.profileInfo?.displayName) + assertNull(state.error) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `api key changes are reflected in ui state`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + apiKeyManager.keyFlow.value = "hevy-key-123" + + awaitUntil { viewModel.uiState.value.apiKey == "hevy-key-123" } + } + + // endregion + + // region Actions + + @Test + fun `updateWeeklyGoal persists and reflects immediately`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.updateWeeklyGoal(4) + println("DBG-called updateWeeklyGoal(4)") + + awaitUntil { + println("DBG weeklyGoal=" + viewModel.uiState.value.weeklyGoal + " loadingState=" + viewModel.uiState.value.loadingState) + viewModel.uiState.value.weeklyGoal == 4 + } + kotlinx.coroutines.runBlocking { assertEquals(4, userPreferences.getWeeklyGoal()) } + } + + @Test + fun `updateTheme and updateUnits persist and reflect`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.updateTheme("Dark") + viewModel.updateUnits("Metric") + + awaitUntil { viewModel.uiState.value.theme == "Dark" && viewModel.uiState.value.units == "Metric" } + kotlinx.coroutines.runBlocking { + assertEquals("Dark", userPreferences.getTheme()) + assertEquals("Metric", userPreferences.getUnits()) + } + } + + @Test + fun `syncData success settles sync state and records last sync`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.syncData() + + viewModel.uiState.test { + val state = nextMatching { it.syncState is LoadingState.Success } + assertNull(state.syncError) + cancelAndIgnoreRemainingEvents() + } + awaitUntil { syncPreferences.getLastSyncAt() != null } + } + + @Test + fun `syncData failure surfaces sync error without silent profile refresh`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + val profileBeforeSync = viewModel.uiState.value.profileInfo + repository.profileInfo = ProfileInfo("NewName", 1000L, false, 9, 40) + repository.syncResult = Result.Error(ApiError.AuthError.InvalidApiKey) + + viewModel.syncData() + + viewModel.uiState.test { + val state = nextMatching { it.syncState is LoadingState.Error } + assertNotNull(state.syncError) + assertTrue(state.syncError is UiError.Auth) + // Silent refresh is skipped on error: profile stays as loaded during init. + assertEquals(profileBeforeSync?.displayName, state.profileInfo?.displayName) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `validateAndSaveApiKey rejects short keys with a message`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + var successCallback = false + + viewModel.validateAndSaveApiKey("short") { successCallback = true } + + viewModel.uiState.test { + val state = nextMatching { it.apiKeyError != null } + assertEquals("API key must be at least 10 characters", state.apiKeyError) + cancelAndIgnoreRemainingEvents() + } + assertTrue(apiKeyManager.savedKeys.isEmpty()) + assertFalse(successCallback) + + viewModel.clearApiKeyError() + assertNull(viewModel.uiState.value.apiKeyError) + } + + @Test + fun `validateAndSaveApiKey stores valid keys and invokes callback`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + var successCallback = false + + viewModel.validateAndSaveApiKey("hevy-valid-key") { successCallback = true } + + awaitUntil { viewModel.uiState.value.apiKey == "hevy-valid-key" } + assertEquals(listOf("hevy-valid-key"), apiKeyManager.savedKeys) + assertTrue(successCallback) + assertNull(viewModel.uiState.value.apiKeyError) + } + + @Test + fun `toggles mirror preferences into ui state`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.updateViewOnlyMode(false) + viewModel.updateNotificationsEnabled(false) + viewModel.updateForceAiEnable(true) + + awaitUntil { + !viewModel.uiState.value.viewOnlyMode && + !viewModel.uiState.value.notificationsEnabled && + viewModel.uiState.value.forceAiEnable + } + kotlinx.coroutines.runBlocking { assertFalse(userPreferences.getViewOnlyMode()) } + } + + @Test + fun `health connect toggles reflect immediately`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.setHealthConnectEnabled(true) + viewModel.setHealthConnectWriteEnabled(true) + + awaitUntil { viewModel.uiState.value.healthConnectWriteEnabled } + assertTrue(viewModel.uiState.value.healthConnectEnabled) + } + + @Test + fun `updateDisplayName updates profile copy when profile exists`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.updateDisplayName("Jordan") + + awaitUntil { viewModel.uiState.value.profileInfo?.displayName == "Jordan" } + } + + @Test + fun `clearCache clears repository cache`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + viewModel.clearCache() + + assertEquals(1, repository.clearCacheCalls) + } + + @Test + fun `refresh silently reloads profile info`() = runVmTest { + val viewModel = buildViewModel() + awaitInitialLoad(testScheduler, viewModel) + + repository.profileInfo = ProfileInfo("Refreshed", 1000L, false, 12, 50) + viewModel.refresh() + + viewModel.uiState.test { + val state = nextMatching { it.profileInfo?.displayName == "Refreshed" } + assertEquals(LoadingState.Success, state.loadingState) + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + private object StaticProvider : AiContextProvider { + override suspend fun buildContext(userQuery: String?) = + com.jdluu.flexinsight.data.ai.HevyAiDataAccessor.ContextSnapshot( + text = "System Context", + hasWorkoutData = true, + hasApiKey = true, + workoutCount = 1, + usesLiveExerciseHistory = false + ) + } + + /** + * Resolves the init delay and waits for the initial load to finish so later user + * actions cannot be overwritten by a late silent reload reading stale preferences. + */ + private suspend fun awaitInitialLoad( + scheduler: kotlinx.coroutines.test.TestCoroutineScheduler, + viewModel: SettingsViewModel + ) { + scheduler.advanceUntilIdle() + awaitUntil("initial load") { viewModel.uiState.value.loadingState == LoadingState.Success } + } + + /** Polls with real time so DataStore-backed flows (real IO threads) can progress. */ + private suspend fun awaitUntil( + label: String = "condition", + timeoutMs: Long = 10_000, + condition: suspend () -> Boolean + ) { + val start = System.currentTimeMillis() + while (!condition()) { + if (System.currentTimeMillis() - start > timeoutMs) { + throw AssertionError("$label not met within ${timeoutMs}ms") + } + withContext(Dispatchers.IO) { delay(10) } + } + } + + /** Consumes emissions until one satisfies [predicate], then discards the rest. */ + private suspend fun TurbineTestContext.nextMatching(predicate: (T) -> Boolean): T { + var item = awaitItem() + while (!predicate(item)) { + item = awaitItem() + } + cancelAndIgnoreRemainingEvents() + return item + } + + /** + * Runs [block] with Dispatchers.Main bound to an [UnconfinedTestDispatcher] sharing + * this test's scheduler, so view-model coroutines (including init delays) resolve + * on the same virtual clock the assertions observe. + */ + private fun runVmTest(block: suspend kotlinx.coroutines.test.TestScope.() -> Unit) = + kotlinx.coroutines.test.runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + Dispatchers.resetMain() + } + } + +} diff --git a/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/WorkoutDetailViewModelTest.kt b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/WorkoutDetailViewModelTest.kt new file mode 100644 index 0000000..d195051 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/WorkoutDetailViewModelTest.kt @@ -0,0 +1,274 @@ +package com.jdluu.flexinsight.ui.viewmodel + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.jdluu.flexinsight.TestApplication +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.model.SingleWorkoutStats +import com.jdluu.flexinsight.data.preferences.UserPreferencesManager +import com.jdluu.flexinsight.domain.ai.AiContextProvider +import com.jdluu.flexinsight.domain.usecase.BuildAiContextUseCase +import com.jdluu.flexinsight.domain.usecase.ExplainWorkoutUseCase +import com.jdluu.flexinsight.fakes.FakeWorkoutRepository +import com.jdluu.flexinsight.fakes.OneShotFakeAiClient +import com.jdluu.flexinsight.fakes.resetForTests +import com.jdluu.flexinsight.fakes.ScriptableFlexRepository +import com.jdluu.flexinsight.fakes.TestDefaults +import com.jdluu.flexinsight.ui.common.LoadingState +import com.jdluu.flexinsight.ui.common.UiError +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = TestApplication::class) +class WorkoutDetailViewModelTest { + + private lateinit var repository: ScriptableFlexRepository + private lateinit var workoutRepository: FakeWorkoutRepository + private lateinit var userPreferences: UserPreferencesManager + private lateinit var aiClient: OneShotFakeAiClient + + @Before + fun setUp() { + + repository = ScriptableFlexRepository() + workoutRepository = FakeWorkoutRepository() + userPreferences = UserPreferencesManager(ApplicationProvider.getApplicationContext()) + kotlinx.coroutines.runBlocking { userPreferences.resetForTests() } + aiClient = OneShotFakeAiClient(available = false) + + repository.workoutById = TestDefaults.workout("w1", startTime = 0L, endTime = 3600_000L) + repository.workoutStatsResult = SingleWorkoutStats(60, 9, 1200.0) + repository.exercisesByWorkoutId = mapOf( + "w1" to listOf( + TestDefaults.exercise("e1", "w1", name = "Bench Press"), + TestDefaults.exercise("e2", "w1", name = "Row") + ) + ) + repository.setsByExerciseId = mapOf( + "e1" to listOf( + TestDefaults.set("s1", "e1", weight = 100.0, reps = 5), + TestDefaults.set("s2", "e1", weight = 102.5, reps = 5) + ), + "e2" to listOf(TestDefaults.set("s3", "e2", weight = 70.0, reps = 8)) + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun buildViewModel(savedStateHandle: SavedStateHandle): WorkoutDetailViewModel = + WorkoutDetailViewModel( + repository = repository, + userPreferencesManager = userPreferences, + aiClient = aiClient, + explainWorkoutUseCase = ExplainWorkoutUseCase( + flexRepository = repository, + workoutRepository = workoutRepository, + aiClient = aiClient, + buildAiContextUseCase = BuildAiContextUseCase(StaticProvider) + ), + savedStateHandle = savedStateHandle + ) + + // region Initialization + + @Test + fun `missing workout id surfaces immediate error without loading`() = runVmTest { + val viewModel = buildViewModel(SavedStateHandle()) + + viewModel.uiState.test { + val state = nextMatching { it.loadingState is LoadingState.Error } + assertEquals("No workout ID provided", state.error?.message) + assertNull(state.workout) + assertTrue(aiClient.prompts.isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `init loads workout with stats, exercises and sets`() = runVmTest { + val viewModel = buildViewModel(SavedStateHandle(mapOf("workoutId" to "w1"))) + + viewModel.uiState.test { + val state = nextMatching { it.loadingState == LoadingState.Success } + assertEquals("w1", state.workout?.id) + assertEquals(60L, state.workoutStats?.durationMinutes) + assertEquals(1200.0, state.workoutStats?.totalVolume) + assertEquals(2, state.exercisesWithSets.size) + assertEquals(2, state.exercisesWithSets.first { it.exercise.id == "e1" }.sets.size) + assertEquals(1, state.exercisesWithSets.first { it.exercise.id == "e2" }.sets.size) + assertNull(state.error) + cancelAndIgnoreRemainingEvents() + } + assertTrue(aiClient.prompts.isEmpty()) + } + + @Test + fun `unknown workout id surfaces not-found error`() = runVmTest { + repository.workoutById = null + val viewModel = buildViewModel(SavedStateHandle(mapOf("workoutId" to "ghost"))) + + viewModel.uiState.test { + val state = nextMatching { it.loadingState is LoadingState.Error } + assertEquals("Workout not found", state.error?.message) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `repository failure surfaces mapped error state`() = runVmTest { + repository.exercisesError = IOException("exercise read boom") + val viewModel = buildViewModel(SavedStateHandle(mapOf("workoutId" to "w1"))) + + viewModel.uiState.test { + val state = nextMatching { it.loadingState is LoadingState.Error } + assertTrue(state.error is UiError.Network) + assertEquals("Unable to connect to server", state.error?.message) + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + // region Actions + + @Test + fun `ai reflection generated when client is available`() = runVmTest { + aiClient.available = true + aiClient.response = Result.Success("Great intensity overall.") + val viewModel = buildViewModel(SavedStateHandle(mapOf("workoutId" to "w1"))) + + viewModel.uiState.test { + val state = nextMatching { it.aiReflection != null } + assertEquals("Great intensity overall.", state.aiReflection) + assertFalse(state.isGeneratingReflection) + cancelAndIgnoreRemainingEvents() + } + assertTrue(aiClient.prompts.single().contains("Bench Press (2 sets)")) + } + + @Test + fun `explainWorkout shows generated explanation on success`() = runVmTest { + val viewModel = buildViewModel(SavedStateHandle(mapOf("workoutId" to "w1"))) + awaitSuccess(viewModel) + aiClient.response = Result.Success("Highlights: solid volume.") + + viewModel.explainWorkout() + + viewModel.uiState.test { + val state = nextMatching { it.workoutExplanation != null && !it.isExplainingWorkout } + assertEquals("Highlights: solid volume.", state.workoutExplanation) + assertFalse(state.isExplainingWorkout) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `explainWorkout falls back to error message on failure`() = runVmTest { + val viewModel = buildViewModel(SavedStateHandle(mapOf("workoutId" to "w1"))) + awaitSuccess(viewModel) + aiClient.response = Result.Error(ApiError.Unknown("model busy")) + + viewModel.explainWorkout() + + viewModel.uiState.test { + val state = nextMatching { it.workoutExplanation != null && !it.isExplainingWorkout } + assertEquals("model busy", state.workoutExplanation) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `refresh reloads workout data from repository`() = runVmTest { + val viewModel = buildViewModel(SavedStateHandle(mapOf("workoutId" to "w1"))) + awaitSuccess(viewModel) + + repository.setsByExerciseId = mapOf( + "e1" to listOf( + TestDefaults.set("s1", "e1", weight = 105.0, reps = 5), + TestDefaults.set("s2", "e1", weight = 107.5, reps = 5), + TestDefaults.set("s4", "e1", weight = 110.0, reps = 3) + ) + ) + viewModel.refresh() + + viewModel.uiState.test { + val state = nextMatching { + it.exercisesWithSets.firstOrNull { e -> e.exercise.id == "e1" }?.sets?.size == 3 + } + assertEquals(3, state.exercisesWithSets.first { it.exercise.id == "e1" }.sets.size) + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + private suspend fun awaitSuccess(viewModel: WorkoutDetailViewModel) { + viewModel.uiState.test { + nextMatching { it.loadingState == LoadingState.Success } + cancelAndIgnoreRemainingEvents() + } + } + + /** Consumes emissions until one satisfies [predicate], then discards the rest. */ + private suspend fun TurbineTestContext.nextMatching(predicate: (T) -> Boolean): T { + var item = awaitItem() + while (!predicate(item)) { + item = awaitItem() + } + cancelAndIgnoreRemainingEvents() + return item + } + + private object StaticProvider : AiContextProvider { + override suspend fun buildContext(userQuery: String?): HevyAiDataAccessor.ContextSnapshot = + HevyAiDataAccessor.ContextSnapshot( + text = "System Context", + hasWorkoutData = true, + hasApiKey = true, + workoutCount = 1, + usesLiveExerciseHistory = false + ) + } + + /** + * Runs [block] with Dispatchers.Main bound to an [UnconfinedTestDispatcher] sharing + * this test's scheduler, so view-model coroutines (including init delays) resolve + * on the same virtual clock the assertions observe. + */ + private fun runVmTest(block: suspend kotlinx.coroutines.test.TestScope.() -> Unit) = + kotlinx.coroutines.test.runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + Dispatchers.resetMain() + } + } + +}