From 0ef7f0e89a3cb40fe68ced8b6c58fe7eee3d804f Mon Sep 17 00:00:00 2001 From: jdluu Date: Tue, 25 Aug 2026 21:10:13 -0700 Subject: [PATCH] refactor: extract pure PromptBuilder, narrow sync/api-key abstractions - Move all prompt assembly from HevyAiDataAccessor into a pure domain/ai/PromptBuilder (no Android imports); accessor now only fetches data and delegates formatting. Output strings byte-identical. - Extract ApiKeyStatusSource, ManualSyncScheduler, SyncCompleteListener interfaces so AITrainerViewModel depends on abstractions instead of ApiKeyManager/SyncManager/SyncCoordinator concrete classes; bound in new SyncModule Hilt module. - Add PromptBuilder characterization tests (20) and AITrainerViewModel Turbine state tests (11); suite grows to 318 tests. --- .../flexinsight/data/ai/HevyAiDataAccessor.kt | 434 ++++++--------- .../data/preferences/ApiKeyManager.kt | 13 +- .../flexinsight/data/sync/SyncCoordinator.kt | 9 +- .../flexinsight/data/sync/SyncManager.kt | 9 +- .../com/jdluu/flexinsight/di/SyncModule.kt | 34 ++ .../flexinsight/domain/ai/PromptBuilder.kt | 421 +++++++++++++++ .../ui/viewmodel/AITrainerViewModel.kt | 12 +- .../domain/ai/PromptBuilderTest.kt | 506 ++++++++++++++++++ .../jdluu/flexinsight/fakes/FakeAiClient.kt | 56 ++ .../ui/viewmodel/AITrainerViewModelTest.kt | 314 +++++++++++ 10 files changed, 1516 insertions(+), 292 deletions(-) create mode 100644 app/src/main/java/com/jdluu/flexinsight/di/SyncModule.kt create mode 100644 app/src/main/java/com/jdluu/flexinsight/domain/ai/PromptBuilder.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/domain/ai/PromptBuilderTest.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/fakes/FakeAiClient.kt create mode 100644 app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/AITrainerViewModelTest.kt 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 980f089..e92a456 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 @@ -1,8 +1,6 @@ package com.jdluu.flexinsight.data.ai import com.jdluu.flexinsight.core.errors.Result -import com.jdluu.flexinsight.data.model.ExerciseHistoryEntry -import com.jdluu.flexinsight.data.model.Set as WorkoutSet import com.jdluu.flexinsight.data.health.HealthConnectRepository import com.jdluu.flexinsight.data.preferences.ApiKeyManager import com.jdluu.flexinsight.data.preferences.UserPreferencesManager @@ -12,10 +10,8 @@ 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 com.jdluu.flexinsight.domain.ai.PromptBuilder import kotlinx.coroutines.flow.first -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -25,6 +21,9 @@ import javax.inject.Singleton * Data path: Hevy API → Room (background sync) → this accessor → prompt context. * On-device Gemini Nano does not support MCP/function calling, so we pre-fetch and * inject structured context, with optional live Hevy API calls for exercise history. + * + * Fetching and failure isolation live here; prompt assembly is delegated to the pure + * [PromptBuilder] so its output can be pinned by characterization tests. */ @Singleton class HevyAiDataAccessor @Inject constructor( @@ -35,7 +34,8 @@ class HevyAiDataAccessor @Inject constructor( private val exerciseRepository: ExerciseRepository, private val userPreferencesManager: UserPreferencesManager, private val apiKeyManager: ApiKeyManager, - private val healthConnectRepository: HealthConnectRepository + private val healthConnectRepository: HealthConnectRepository, + private val promptBuilder: PromptBuilder ) : AiContextProvider { data class ContextSnapshot( @@ -57,11 +57,7 @@ class HevyAiDataAccessor @Inject constructor( if (!hasApiKey) { return ContextSnapshot( - text = """ - System Context - Hevy Data: - - API key: Not configured. The user must add a Hevy API key in Settings to sync workout history. - - Instruction: Explain that personalized coaching requires connecting Hevy, then offer general fitness guidance only. - """.trimIndent(), + text = promptBuilder.noApiKeyContext(), hasWorkoutData = false, hasApiKey = false, workoutCount = 0 @@ -70,334 +66,214 @@ class HevyAiDataAccessor @Inject constructor( if (workoutCount == 0) { return ContextSnapshot( - text = """ - System Context - Hevy Data: - - API key: Connected. - - Workouts synced: 0. Data may still be downloading — suggest the user pull to refresh or open Settings to sync. - - Instruction: Offer general coaching; mention that insights will improve once Hevy workouts sync. - """.trimIndent(), + text = promptBuilder.zeroWorkoutContext(), hasWorkoutData = false, hasApiKey = true, workoutCount = 0 ) } - val units = userPreferencesManager.getUnits() - val weightUnit = if (units == "Metric") "kg" else "lbs" - val sb = StringBuilder() - sb.appendLine("System Context - Hevy Training Data (synced from Hevy API):") - sb.appendLine("- Data source: Local cache of Hevy workouts; refreshed on app sync.") - sb.appendLine("- Total logged workouts: $workoutCount") - - appendProfile(sb) - appendAggregateStats(sb, weightUnit) - appendRecentWorkouts(sb, weightUnit, limit = 7) - appendConsistency(sb) - appendPersonalRecords(sb, weightUnit) - appendMuscleFatigue(sb) - appendMuscleRecovery(sb) - appendVolumeBalance(sb) - appendRoutinesAndPlanned(sb) - appendProgressiveOverloadNote(sb) - - val usesLiveApi = userQuery?.let { query -> - appendQuerySpecificHistory(sb, query, weightUnit) - } ?: false - - appendHealthConnect(sb) - - sb.appendLine() - sb.appendLine( - "Instruction: You are an expert strength coach. Reference the user's actual Hevy numbers, " + - "routines, PRs, and recovery state. Be specific — use exercise names, weights, and dates from above. " + - "If asked about data not listed, say what you do have and suggest they log it in Hevy." - ) - - val text = trimToTokenBudget(sb.toString(), maxChars = 12_000) - - return ContextSnapshot( - text = text, - hasWorkoutData = true, - usesLiveExerciseHistory = usesLiveApi, - hasApiKey = true, - workoutCount = workoutCount - ) - } - - private suspend fun appendProfile(sb: StringBuilder) { - val displayName = userPreferencesManager.getDisplayName() ?: "Athlete" - val goal = userPreferencesManager.getWeeklyGoal() - val units = userPreferencesManager.getUnits() - sb.appendLine("- Name: $displayName") - sb.appendLine("- Preferred units: $units") - sb.appendLine("- Weekly frequency goal: $goal sessions") - try { - val profile = flexRepository.getProfileInfo() - profile.memberSince?.let { - val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - sb.appendLine("- Member since: ${fmt.format(Date(it))}") + val profile = PromptBuilder.ProfileInput( + displayName = userPreferencesManager.getDisplayName() ?: "Athlete", + weeklyGoalSessions = userPreferencesManager.getWeeklyGoal(), + units = userPreferencesManager.getUnits(), + memberSinceMillis = try { + flexRepository.getProfileInfo().memberSince + } catch (_: Exception) { + null } - } catch (_: Exception) { } - } + ) - private suspend fun appendAggregateStats(sb: StringBuilder, weightUnit: String) { - try { + // Mirrors the original single try-block: pieces captured before a failure stay. + val trainingSummary = try { val stats = flexRepository.calculateStats() - sb.appendLine() - sb.appendLine("Training summary:") - sb.appendLine("- Total volume (all time): ${formatWeight(stats.totalVolume, weightUnit)}") - sb.appendLine("- Average session volume: ${formatWeight(stats.averageVolume, weightUnit)}") - sb.appendLine("- Current streak: ${stats.currentStreak} days") - sb.appendLine("- Longest streak: ${stats.longestStreak} days") - val trend = flexRepository.calculateVolumeTrend(weeks = 4) - val change = "%.1f".format(trend.percentageChange) - sb.appendLine("- 4-week volume trend: $change% vs prior period") - val goalProgress = flexRepository.getWeeklyGoalProgress() - sb.appendLine("- This week: ${goalProgress.completed}/${goalProgress.target} workouts (${goalProgress.status})") + PromptBuilder.TrainingSummary( + base = PromptBuilder.BaseSummary( + totalVolumeKg = stats.totalVolume, + averageSessionVolumeKg = stats.averageVolume, + currentStreakDays = stats.currentStreak, + longestStreakDays = stats.longestStreak + ), + fourWeekTrendPctChange = trend.percentageChange, + weeklyGoal = PromptBuilder.WeeklyGoalLine( + completed = goalProgress.completed, + target = goalProgress.target, + status = goalProgress.status + ) + ) } catch (e: Exception) { android.util.Log.e(TAG, "Failed aggregate stats", e) + PromptBuilder.TrainingSummary(base = null, fourWeekTrendPctChange = null, weeklyGoal = null) } - } - private suspend fun appendRecentWorkouts(sb: StringBuilder, weightUnit: String, limit: Int) { - val recent = try { - workoutRepository.getRecentWorkouts(limit).first() + val recentWorkouts = try { + workoutRepository.getRecentWorkouts(RECENT_WORKOUT_LIMIT).first().map { workout -> + PromptBuilder.RecentWorkout( + startTimeMillis = workout.startTime, + name = workout.name, + exercises = workoutRepository.getExercisesByWorkoutId(workout.id).map { exercise -> + PromptBuilder.ExerciseSets( + name = exercise.name, + sets = workoutRepository.getSetsByExerciseId(exercise.id).map { set -> + PromptBuilder.SetData(weightKg = set.weight, reps = set.reps, rpe = set.rpe) + } + ) + } + ) + } } catch (e: Exception) { android.util.Log.e(TAG, "Failed recent workouts", e) emptyList() } - if (recent.isEmpty()) return - - sb.appendLine() - sb.appendLine("Recently completed workouts (last $limit sessions):") - val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - - recent.forEachIndexed { index, workout -> - sb.appendLine("${index + 1}. ${dateFormat.format(Date(workout.startTime))}: ${workout.name}") - val exercises = workoutRepository.getExercisesByWorkoutId(workout.id) - exercises.forEach { exercise -> - sb.append(" * ${exercise.name}: ") - val sets = workoutRepository.getSetsByExerciseId(exercise.id) - sb.appendLine(formatSets(sets, weightUnit)) - } + val consistencySessions = try { + statsRepository.getConsistencyData(days = 14).count { it.hasWorkout } + } catch (_: Exception) { + null } - } - - private suspend fun appendConsistency(sb: StringBuilder) { - try { - val days = statsRepository.getConsistencyData(14) - val sessions = days.count { it.hasWorkout } - sb.appendLine("- Consistency: $sessions workouts in the last 14 days") - } catch (_: Exception) { } - } - private suspend fun appendPersonalRecords(sb: StringBuilder, weightUnit: String) { - val prs = try { + val personalRecords = try { statsRepository.getPRsWithDetails(limit = 15) } catch (_: Exception) { emptyList() + }.map { pr -> + PromptBuilder.PersonalRecord( + exerciseName = pr.exerciseName, + muscleGroup = pr.muscleGroup, + weightKg = pr.weight, + dateMillis = pr.date + ) } - if (prs.isEmpty()) return - - sb.appendLine() - sb.appendLine("Personal records:") - prs.forEach { pr -> - val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(pr.date)) - sb.appendLine("- ${pr.exerciseName} (${pr.muscleGroup}): ${formatWeight(pr.weight, weightUnit)} on $date") - } - } - private suspend fun appendMuscleFatigue(sb: StringBuilder) { - val fatigue = try { + val highVolumeMuscles = try { statsRepository.getMuscleGroupProgress(weeks = 1) } catch (_: Exception) { emptyList() - } - val high = fatigue.filter { it.intensity == "HI" }.map { it.muscleGroup } - if (high.isNotEmpty()) { - sb.appendLine("- High recent volume muscles: ${high.joinToString(", ")}") - } - } + }.filter { it.intensity == "HI" }.map { it.muscleGroup } - private suspend fun appendMuscleRecovery(sb: StringBuilder) { - try { - val recovery = flexRepository.getMuscleRecoveryStatus() - val notRecovered = recovery - .filter { (_, pct) -> pct < 0.5f } - .map { (group, pct) -> "${group.name} (${(pct * 100).toInt()}% recovered)" } - if (notRecovered.isNotEmpty()) { - sb.appendLine("- Muscles still recovering (<50%): ${notRecovered.joinToString(", ")}") - } - } catch (_: Exception) { } - } + val recoveringMuscles = try { + flexRepository.getMuscleRecoveryStatus() + } catch (_: Exception) { + emptyMap() + }.mapNotNull { (group, pct) -> + if (pct < 0.5f) PromptBuilder.RecoveringMuscle(group.name, pct) else null + } - private suspend fun appendVolumeBalance(sb: StringBuilder) { - try { - val balance = flexRepository.getVolumeBalance(weeks = 4) - sb.appendLine( - "- Push/Pull/Legs volume split (4 wks): " + - "Push ${pct(balance.push)}, Pull ${pct(balance.pull)}, Legs ${pct(balance.legs)}" - ) - } catch (_: Exception) { } - } + val volumeBalance = try { + flexRepository.getVolumeBalance(weeks = 4) + } catch (_: Exception) { + null + }?.let { PromptBuilder.VolumeBalanceSplit(it.push, it.pull, it.legs) } - private suspend fun appendRoutinesAndPlanned(sb: StringBuilder) { val routines = try { routineRepository.getRoutines().first() } catch (_: Exception) { emptyList() - } - if (routines.isNotEmpty()) { - sb.appendLine() - sb.appendLine("Saved Hevy routines:") - routines.take(8).forEach { routine -> - val names = routine.exercises?.joinToString { it.name ?: "?" } ?: "—" - sb.appendLine("- ${routine.name}: $names") - } + }.map { routine -> + PromptBuilder.RoutineSection( + name = routine.name, + exerciseNames = routine.exercises?.map { it.name ?: "?" } + ) } - val planned = try { + val plannedToday = try { statsRepository.getPlannedWorkoutsForDay(System.currentTimeMillis()) } catch (_: Exception) { emptyList() - } - if (planned.isNotEmpty()) { - sb.appendLine() - sb.appendLine("Planned for today:") - planned.forEach { p -> - sb.appendLine("- ${p.name} (${p.intensity ?: "planned"})") - } - } - } + }.map { p -> PromptBuilder.PlannedWorkoutSection(name = p.name, intensity = p.intensity) } - private suspend fun appendProgressiveOverloadNote(sb: StringBuilder) { - val latest = try { + val latestSession = try { workoutRepository.getRecentWorkouts(1).first().firstOrNull() } catch (_: Exception) { null - } ?: return + }?.let { PromptBuilder.LatestSession(startTimeMillis = it.startTime, name = it.name) } + + val historyLookups = userQuery?.let { query -> + resolveExerciseHistoryLookups(query) + } ?: emptyList() + + val healthConnect = if (!userPreferencesManager.getHealthConnectEnabled()) { + PromptBuilder.HealthConnectInput.Disabled + } else { + healthConnectRepository.readSnapshot().let { health -> + when { + !health.isAvailable -> PromptBuilder.HealthConnectInput.Unavailable + !health.isPermissionGranted -> PromptBuilder.HealthConnectInput.PermissionMissing + else -> PromptBuilder.HealthConnectInput.Snapshot( + sleepHoursLastNight = health.sleepHoursLastNight, + restingHeartRateBpm = health.restingHeartRateBpm, + stepsToday = health.stepsToday, + activeCaloriesToday = health.activeCaloriesToday, + cardioSessionsThisWeek = health.cardioSessionsThisWeek + ) + } + } + } + + val output = promptBuilder.buildTrainingContext( + PromptBuilder.TrainingContextInput( + workoutCount = workoutCount, + profile = profile, + trainingSummary = trainingSummary, + recentWorkouts = recentWorkouts, + consistencySessionsLast14Days = consistencySessions, + personalRecords = personalRecords, + highVolumeMuscles = highVolumeMuscles, + recoveringMuscles = recoveringMuscles, + volumeBalance = volumeBalance, + routines = routines, + plannedToday = plannedToday, + latestSession = latestSession, + exerciseHistoryLookups = historyLookups, + healthConnect = healthConnect + ) + ) - val fmt = SimpleDateFormat("MMM dd", Locale.getDefault()) - sb.appendLine() - sb.appendLine("Latest session (${fmt.format(Date(latest.startTime))} — ${latest.name}):") - sb.appendLine("- Coach note: suggest +2.5% weight or +1 rep on main lifts vs that session when programming progress.") + return ContextSnapshot( + text = output.text, + hasWorkoutData = true, + usesLiveExerciseHistory = output.usesLiveExerciseHistory, + hasApiKey = true, + workoutCount = workoutCount + ) } /** * Live Hevy API tool: fetches exercise history when the user asks about a specific lift. */ - private suspend fun appendQuerySpecificHistory(sb: StringBuilder, query: String, weightUnit: String): Boolean { - val templateIds = findRelevantTemplateIds(query) - if (templateIds.isEmpty()) return false - - sb.appendLine() - sb.appendLine("Exercise history for this question (from Hevy API):") - - for ((name, templateId) in templateIds) { - when (val result = flexRepository.getExerciseHistory(templateId)) { - is Result.Success -> { - val entries = result.data.history.take(5) - if (entries.isEmpty()) { - sb.appendLine("- $name: no history entries") - continue - } - sb.appendLine("- $name:") - entries.forEach { entry -> - sb.appendLine(" ${formatHistoryEntry(entry, weightUnit)}") - } - } - is Result.Error -> { - sb.appendLine("- $name: history unavailable (${result.error.message})") - } - } - } - return true - } - - private suspend fun findRelevantTemplateIds(query: String): List> { - val queryLower = query.lowercase(Locale.getDefault()) - val tokens = queryLower.split(Regex("\\W+")).filter { it.length >= 4 }.toSet() - - val exercises = try { + private suspend fun resolveExerciseHistoryLookups(query: String): List { + val candidates = try { exerciseRepository.getAllExercises().first() } catch (_: Exception) { emptyList() - } - - return exercises - .filter { ex -> - ex.exerciseTemplateId != null && - tokens.any { token -> ex.name.lowercase(Locale.getDefault()).contains(token) } + }.filter { it.exerciseTemplateId != null } + .map { PromptBuilder.ExerciseCandidate(name = it.name, templateId = it.exerciseTemplateId!!) } + + val matches = promptBuilder.matchExerciseTemplates(query, candidates) + + return matches.map { match -> + val outcome = when (val result = flexRepository.getExerciseHistory(match.templateId)) { + is Result.Success -> PromptBuilder.ExerciseHistoryOutcome.Success( + result.data.history.map { entry -> + PromptBuilder.HistoryEntryData( + dateIso = entry.date, + sets = entry.sets?.map { set -> + PromptBuilder.HistorySetData(weightKg = set.weightKg, reps = set.reps) + }, + oneRepMaxKg = entry.oneRepMax + ) + } + ) + is Result.Error -> PromptBuilder.ExerciseHistoryOutcome.Failure(reason = result.error.message) } - .distinctBy { it.exerciseTemplateId } - .take(2) - .map { it.name to it.exerciseTemplateId!! } - } - - private fun formatHistoryEntry(entry: ExerciseHistoryEntry, weightUnit: String): String { - val date = entry.date.take(10) - val best = entry.sets?.maxByOrNull { (it.weightKg ?: 0.0) * (it.reps ?: 0) } - val bestStr = best?.let { - "${formatWeight(it.weightKg ?: 0.0, weightUnit)} x ${it.reps ?: 0} reps" - } ?: "—" - val e1rm = entry.oneRepMax?.let { "e1RM ${formatWeight(it, weightUnit)}" } ?: "" - return "$date: best set $bestStr $e1rm".trim() - } - - private fun formatSets(sets: List, weightUnit: String): String = - sets.mapNotNull { set -> - val w = set.weight?.let { formatWeight(it, weightUnit) } - val r = set.reps?.let { "${it}r" } - val rpe = set.rpe?.let { "@RPE$it" } - listOfNotNull(w, r, rpe).joinToString(" ").takeIf { it.isNotEmpty() } - }.joinToString(" | ") - - private fun formatWeight(kg: Double, unit: String): String { - val value = if (unit == "lbs") kg * 2.20462 else kg - return "${value.toInt()} $unit" - } - - private fun pct(value: Float) = "${(value * 100).toInt()}%" - - private suspend fun appendHealthConnect(sb: StringBuilder) { - if (!userPreferencesManager.getHealthConnectEnabled()) return - val health = healthConnectRepository.readSnapshot() - if (!health.isAvailable) { - sb.appendLine("- Health Connect: not available on this device") - return - } - if (!health.isPermissionGranted) { - sb.appendLine("- Health Connect: enabled but permissions not granted") - return - } - sb.appendLine() - sb.appendLine("Health Connect (last 24h / week):") - health.sleepHoursLastNight?.let { - sb.appendLine("- Sleep last night: ${"%.1f".format(it)} hours") - } - health.restingHeartRateBpm?.let { - sb.appendLine("- Resting heart rate: $it bpm") + PromptBuilder.ExerciseHistoryLookup(exerciseName = match.name, outcome = outcome) } - health.stepsToday?.let { - sb.appendLine("- Steps today: $it") - } - health.activeCaloriesToday?.let { - sb.appendLine("- Active calories today: ${it.toInt()} kcal") - } - if (health.cardioSessionsThisWeek > 0) { - sb.appendLine("- Non-strength sessions this week: ${health.cardioSessionsThisWeek}") - } - } - - private fun trimToTokenBudget(text: String, maxChars: Int): String { - if (text.length <= maxChars) return text - return text.take(maxChars) + "\n…[context truncated for on-device token limit]" } companion object { private const val TAG = "HevyAiDataAccessor" + private const val RECENT_WORKOUT_LIMIT = 7 } } diff --git a/app/src/main/java/com/jdluu/flexinsight/data/preferences/ApiKeyManager.kt b/app/src/main/java/com/jdluu/flexinsight/data/preferences/ApiKeyManager.kt index 8736b16..9168cff 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/preferences/ApiKeyManager.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/preferences/ApiKeyManager.kt @@ -22,6 +22,14 @@ private val Context.legacyApiKeyDataStore: DataStore by preferences name = "api_key_preferences" ) +/** + * Read-only view of Hevy API key availability, for callers that only need to + * check connectivity status (e.g. AI Trainer pre-chat sync). + */ +interface ApiKeyStatusSource { + suspend fun hasApiKey(): Boolean +} + /** * Stores the Hevy API key in encrypted shared preferences. * Migrates keys from the legacy DataStore on first access. @@ -29,7 +37,7 @@ private val Context.legacyApiKeyDataStore: DataStore by preferences @Singleton class ApiKeyManager @Inject constructor( private val context: Context -) { +) : ApiKeyStatusSource { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -75,8 +83,7 @@ class ApiKeyManager @Inject constructor( _apiKeyFlow.value = null } - suspend fun hasApiKey(): Boolean = !getApiKey().isNullOrBlank() - + override suspend fun hasApiKey(): Boolean = !getApiKey().isNullOrBlank() fun isValidApiKeyFormat(apiKey: String): Boolean { return apiKey.isNotBlank() && apiKey.length >= 10 } diff --git a/app/src/main/java/com/jdluu/flexinsight/data/sync/SyncCoordinator.kt b/app/src/main/java/com/jdluu/flexinsight/data/sync/SyncCoordinator.kt index ae654d9..bd50f1c 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/sync/SyncCoordinator.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/sync/SyncCoordinator.kt @@ -7,13 +7,18 @@ import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton +/** Notified when a full Hevy sync finishes, to run post-sync follow-ups. */ +interface SyncCompleteListener { + suspend fun onSyncComplete() +} + @Singleton class SyncCoordinator @Inject constructor( private val workoutRepository: WorkoutRepository, private val syncPreferencesManager: SyncPreferencesManager, private val healthConnectRepository: HealthConnectRepository -) { - suspend fun onSyncComplete() { +) : SyncCompleteListener { + override suspend fun onSyncComplete() { val count = workoutRepository.getWorkoutCount().first() syncPreferencesManager.recordSyncSuccess(count) val recent = workoutRepository.getRecentWorkouts(limit = 5).first() diff --git a/app/src/main/java/com/jdluu/flexinsight/data/sync/SyncManager.kt b/app/src/main/java/com/jdluu/flexinsight/data/sync/SyncManager.kt index b932d3c..8bae310 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/sync/SyncManager.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/sync/SyncManager.kt @@ -8,6 +8,11 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +/** Trigger for user-initiated immediate syncs (e.g. pull to refresh, AI Trainer pre-chat). */ +interface ManualSyncScheduler { + fun syncNow() +} + /** * Manages background synchronization tasks using WorkManager. * Centralizes sync scheduling for the entire application. @@ -15,7 +20,7 @@ import javax.inject.Singleton @Singleton class SyncManager @Inject constructor( @param:ApplicationContext private val context: Context -) { +) : ManualSyncScheduler { private val workManager = WorkManager.getInstance(context) companion object { @@ -28,7 +33,7 @@ class SyncManager @Inject constructor( * Enqueues an immediate one-time sync task. * This is useful when the user completes a workout or manually pulls to refresh. */ - fun syncNow() { + override fun syncNow() { AppLogger.d("Requesting immediate background sync", tag = TAG) val constraints = Constraints.Builder() diff --git a/app/src/main/java/com/jdluu/flexinsight/di/SyncModule.kt b/app/src/main/java/com/jdluu/flexinsight/di/SyncModule.kt new file mode 100644 index 0000000..ec2beae --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/di/SyncModule.kt @@ -0,0 +1,34 @@ +package com.jdluu.flexinsight.di + +import com.jdluu.flexinsight.data.preferences.ApiKeyManager +import com.jdluu.flexinsight.data.preferences.ApiKeyStatusSource +import com.jdluu.flexinsight.data.sync.ManualSyncScheduler +import com.jdluu.flexinsight.data.sync.SyncCompleteListener +import com.jdluu.flexinsight.data.sync.SyncCoordinator +import com.jdluu.flexinsight.data.sync.SyncManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Binds narrow sync/api-key abstractions used by view models so they can be + * faked in unit tests without touching WorkManager or encrypted storage. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class SyncModule { + + @Binds + @Singleton + abstract fun bindApiKeyStatusSource(apiKeyManager: ApiKeyManager): ApiKeyStatusSource + + @Binds + @Singleton + abstract fun bindManualSyncScheduler(syncManager: SyncManager): ManualSyncScheduler + + @Binds + @Singleton + abstract fun bindSyncCompleteListener(syncCoordinator: SyncCoordinator): SyncCompleteListener +} diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/ai/PromptBuilder.kt b/app/src/main/java/com/jdluu/flexinsight/domain/ai/PromptBuilder.kt new file mode 100644 index 0000000..4407a23 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/domain/ai/PromptBuilder.kt @@ -0,0 +1,421 @@ +package com.jdluu.flexinsight.domain.ai + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +/** + * Assembles the AI Trainer system prompt from pre-fetched training data. + * + * Pure domain class: no Android dependencies. All data is supplied via + * [TrainingContextInput]; fetching, caching, and failure isolation live in + * the [com.jdluu.flexinsight.data.ai.HevyAiDataAccessor], which delegates + * formatting here so prompt structure is unit-testable in isolation. + */ +class PromptBuilder @Inject constructor() { + + // region Input models + + data class ExerciseCandidate(val name: String, val templateId: String) + + data class ProfileInput( + val displayName: String, + val weeklyGoalSessions: Int, + /** "Metric" or anything else for imperial; mirrors UserPreferencesManager values. */ + val units: String, + val memberSinceMillis: Long? + ) + + data class BaseSummary( + val totalVolumeKg: Double, + val averageSessionVolumeKg: Double, + val currentStreakDays: Int, + val longestStreakDays: Int + ) + + data class WeeklyGoalLine( + val completed: Int, + val target: Int, + val status: String + ) + + /** + * Mirrors the original single try-block around stats, trend, and weekly goal: + * pieces captured before a failure stay, later ones are absent. + */ + data class TrainingSummary( + val base: BaseSummary?, + val fourWeekTrendPctChange: Double?, + val weeklyGoal: WeeklyGoalLine? + ) + + data class SetData(val weightKg: Double?, val reps: Int?, val rpe: Double?) + + data class ExerciseSets(val name: String, val sets: List) + + data class RecentWorkout( + val startTimeMillis: Long, + val name: String?, + val exercises: List + ) + + data class PersonalRecord( + val exerciseName: String, + val muscleGroup: String, + val weightKg: Double, + val dateMillis: Long + ) + + data class RecoveringMuscle(val groupName: String, val recoveryPct: Float) + + data class VolumeBalanceSplit(val push: Float, val pull: Float, val legs: Float) + + data class RoutineSection(val name: String, /** Null when routine has no exercise list; empty when all names missing. */ val exerciseNames: List?) + + data class PlannedWorkoutSection(val name: String, val intensity: String?) + + data class LatestSession(val startTimeMillis: Long, val name: String?) + + data class HistorySetData(val weightKg: Double?, val reps: Int?) + + data class HistoryEntryData( + val dateIso: String, + val sets: List?, + val oneRepMaxKg: Double? + ) + + sealed interface ExerciseHistoryOutcome { + data class Success(val entries: List) : ExerciseHistoryOutcome + data class Failure(val reason: String?) : ExerciseHistoryOutcome + } + + data class ExerciseHistoryLookup( + val exerciseName: String, + val outcome: ExerciseHistoryOutcome + ) + + sealed interface HealthConnectInput { + data object Disabled : HealthConnectInput + data object Unavailable : HealthConnectInput + data object PermissionMissing : HealthConnectInput + data class Snapshot( + val sleepHoursLastNight: Double?, + val restingHeartRateBpm: Long?, + val stepsToday: Long?, + val activeCaloriesToday: Double?, + val cardioSessionsThisWeek: Int + ) : HealthConnectInput + } + + data class TrainingContextInput( + val workoutCount: Int, + val profile: ProfileInput, + val trainingSummary: TrainingSummary? = null, + val recentWorkouts: List = emptyList(), + /** Sessions with a workout in the last 14 days; null skips the line. */ + val consistencySessionsLast14Days: Int? = null, + val personalRecords: List = emptyList(), + /** Muscle group names with high recent volume. */ + val highVolumeMuscles: List = emptyList(), + val recoveringMuscles: List = emptyList(), + val volumeBalance: VolumeBalanceSplit? = null, + val routines: List = emptyList(), + val plannedToday: List = emptyList(), + val latestSession: LatestSession? = null, + /** Present only when the user query matched exercises; drives live-history flag. */ + val exerciseHistoryLookups: List = emptyList(), + val healthConnect: HealthConnectInput = HealthConnectInput.Disabled + ) + + data class TrainingContextOutput( + val text: String, + /** True when the user query triggered live Hevy exercise-history injection. */ + val usesLiveExerciseHistory: Boolean + ) + + // endregion + + fun noApiKeyContext(): String = """ + System Context - Hevy Data: + - API key: Not configured. The user must add a Hevy API key in Settings to sync workout history. + - Instruction: Explain that personalized coaching requires connecting Hevy, then offer general fitness guidance only. + """.trimIndent() + + fun zeroWorkoutContext(): String = """ + System Context - Hevy Data: + - API key: Connected. + - Workouts synced: 0. Data may still be downloading — suggest the user pull to refresh or open Settings to sync. + - Instruction: Offer general coaching; mention that insights will improve once Hevy workouts sync. + """.trimIndent() + + /** + * Matches user query tokens against known exercise templates. + * Tokens shorter than 4 characters are ignored; results are deduplicated by + * template id and capped at two exercises. + */ + fun matchExerciseTemplates( + query: String, + candidates: List + ): List { + val queryLower = query.lowercase(Locale.getDefault()) + val tokens = queryLower.split(Regex("\\W+")).filter { it.length >= 4 }.toSet() + + return candidates + .filter { candidate -> + tokens.any { token -> candidate.name.lowercase(Locale.getDefault()).contains(token) } + } + .distinctBy { it.templateId } + .take(2) + } + + fun buildTrainingContext(input: TrainingContextInput): TrainingContextOutput { + val weightUnit = if (input.profile.units == "Metric") "kg" else "lbs" + val sb = StringBuilder() + sb.appendLine("System Context - Hevy Training Data (synced from Hevy API):") + sb.appendLine("- Data source: Local cache of Hevy workouts; refreshed on app sync.") + sb.appendLine("- Total logged workouts: ${input.workoutCount}") + + appendProfile(sb, input.profile) + appendAggregateStats(sb, input.trainingSummary, weightUnit) + appendRecentWorkouts(sb, input.recentWorkouts, weightUnit) + appendConsistency(sb, input.consistencySessionsLast14Days) + appendPersonalRecords(sb, input.personalRecords, weightUnit) + appendMuscleFatigue(sb, input.highVolumeMuscles) + appendMuscleRecovery(sb, input.recoveringMuscles) + appendVolumeBalance(sb, input.volumeBalance) + appendRoutinesAndPlanned(sb, input.routines, input.plannedToday) + appendProgressiveOverloadNote(sb, input.latestSession) + + val usesLiveApi = appendQuerySpecificHistory(sb, input.exerciseHistoryLookups, weightUnit) + + appendHealthConnect(sb, input.healthConnect) + + sb.appendLine() + sb.appendLine( + "Instruction: You are an expert strength coach. Reference the user's actual Hevy numbers, " + + "routines, PRs, and recovery state. Be specific — use exercise names, weights, and dates from above. " + + "If asked about data not listed, say what you do have and suggest they log it in Hevy." + ) + + val text = trimToTokenBudget(sb.toString(), maxChars = 12_000) + + return TrainingContextOutput( + text = text, + usesLiveExerciseHistory = usesLiveApi + ) + } + + private fun appendProfile(sb: StringBuilder, profile: ProfileInput) { + sb.appendLine("- Name: ${profile.displayName}") + sb.appendLine("- Preferred units: ${profile.units}") + sb.appendLine("- Weekly frequency goal: ${profile.weeklyGoalSessions} sessions") + profile.memberSinceMillis?.let { + val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + sb.appendLine("- Member since: ${fmt.format(Date(it))}") + } + } + + private fun appendAggregateStats(sb: StringBuilder, summary: TrainingSummary?, weightUnit: String) { + val base = summary?.base ?: return + sb.appendLine() + sb.appendLine("Training summary:") + sb.appendLine("- Total volume (all time): ${formatWeight(base.totalVolumeKg, weightUnit)}") + sb.appendLine("- Average session volume: ${formatWeight(base.averageSessionVolumeKg, weightUnit)}") + sb.appendLine("- Current streak: ${base.currentStreakDays} days") + sb.appendLine("- Longest streak: ${base.longestStreakDays} days") + + summary.fourWeekTrendPctChange?.let { trend -> + val change = "%.1f".format(trend) + sb.appendLine("- 4-week volume trend: $change% vs prior period") + } + + summary.weeklyGoal?.let { goal -> + sb.appendLine("- This week: ${goal.completed}/${goal.target} workouts (${goal.status})") + } + } + + private fun appendRecentWorkouts(sb: StringBuilder, recent: List, weightUnit: String) { + if (recent.isEmpty()) return + + sb.appendLine() + sb.appendLine("Recently completed workouts (last 7 sessions):") + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + + recent.forEachIndexed { index, workout -> + sb.appendLine("${index + 1}. ${dateFormat.format(Date(workout.startTimeMillis))}: ${workout.name}") + workout.exercises.forEach { exercise -> + sb.append(" * ${exercise.name}: ") + sb.appendLine(formatSets(exercise.sets, weightUnit)) + } + } + } + + private fun appendConsistency(sb: StringBuilder, sessionsLast14Days: Int?) { + sessionsLast14Days?.let { + sb.appendLine("- Consistency: $it workouts in the last 14 days") + } + } + + private fun appendPersonalRecords(sb: StringBuilder, prs: List, weightUnit: String) { + if (prs.isEmpty()) return + + sb.appendLine() + sb.appendLine("Personal records:") + prs.forEach { pr -> + val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(pr.dateMillis)) + sb.appendLine("- ${pr.exerciseName} (${pr.muscleGroup}): ${formatWeight(pr.weightKg, weightUnit)} on $date") + } + } + + private fun appendMuscleFatigue(sb: StringBuilder, highVolumeMuscles: List) { + if (highVolumeMuscles.isNotEmpty()) { + sb.appendLine("- High recent volume muscles: ${highVolumeMuscles.joinToString(", ")}") + } + } + + private fun appendMuscleRecovery(sb: StringBuilder, recovering: List) { + val notRecovered = recovering + .filter { it.recoveryPct < 0.5f } + .map { "${it.groupName} (${(it.recoveryPct * 100).toInt()}% recovered)" } + if (notRecovered.isNotEmpty()) { + sb.appendLine("- Muscles still recovering (<50%): ${notRecovered.joinToString(", ")}") + } + } + + private fun appendVolumeBalance(sb: StringBuilder, balance: VolumeBalanceSplit?) { + balance?.let { + sb.appendLine( + "- Push/Pull/Legs volume split (4 wks): " + + "Push ${pct(it.push)}, Pull ${pct(it.pull)}, Legs ${pct(it.legs)}" + ) + } + } + + private fun appendRoutinesAndPlanned( + sb: StringBuilder, + routines: List, + planned: List + ) { + if (routines.isNotEmpty()) { + sb.appendLine() + sb.appendLine("Saved Hevy routines:") + routines.take(8).forEach { routine -> + val names = routine.exerciseNames?.joinToString { it } ?: "—" + sb.appendLine("- ${routine.name}: $names") + } + } + + if (planned.isNotEmpty()) { + sb.appendLine() + sb.appendLine("Planned for today:") + planned.forEach { p -> + sb.appendLine("- ${p.name} (${p.intensity ?: "planned"})") + } + } + } + + private fun appendProgressiveOverloadNote(sb: StringBuilder, latest: LatestSession?) { + latest ?: return + + val fmt = SimpleDateFormat("MMM dd", Locale.getDefault()) + sb.appendLine() + sb.appendLine("Latest session (${fmt.format(Date(latest.startTimeMillis))} — ${latest.name}):") + sb.appendLine("- Coach note: suggest +2.5% weight or +1 rep on main lifts vs that session when programming progress.") + } + + /** Returns true when any lookup was rendered (mirrors the live-API flag). */ + private fun appendQuerySpecificHistory( + sb: StringBuilder, + lookups: List, + weightUnit: String + ): Boolean { + if (lookups.isEmpty()) return false + + sb.appendLine() + sb.appendLine("Exercise history for this question (from Hevy API):") + + for (lookup in lookups) { + when (val outcome = lookup.outcome) { + is ExerciseHistoryOutcome.Success -> { + val entries = outcome.entries.take(5) + if (entries.isEmpty()) { + sb.appendLine("- ${lookup.exerciseName}: no history entries") + continue + } + sb.appendLine("- ${lookup.exerciseName}:") + entries.forEach { entry -> + sb.appendLine(" ${formatHistoryEntry(entry, weightUnit)}") + } + } + is ExerciseHistoryOutcome.Failure -> { + sb.appendLine("- ${lookup.exerciseName}: history unavailable (${outcome.reason})") + } + } + } + return true + } + + private fun formatHistoryEntry(entry: HistoryEntryData, weightUnit: String): String { + val date = entry.dateIso.take(10) + val best = entry.sets?.maxByOrNull { (it.weightKg ?: 0.0) * (it.reps ?: 0) } + val bestStr = best?.let { + "${formatWeight(it.weightKg ?: 0.0, weightUnit)} x ${it.reps ?: 0} reps" + } ?: "—" + val e1rm = entry.oneRepMaxKg?.let { "e1RM ${formatWeight(it, weightUnit)}" } ?: "" + return "$date: best set $bestStr $e1rm".trim() + } + + private fun formatSets(sets: List, weightUnit: String): String = + sets.mapNotNull { set -> + val w = set.weightKg?.let { formatWeight(it, weightUnit) } + val r = set.reps?.let { "${it}r" } + val rpe = set.rpe?.let { "@RPE$it" } + listOfNotNull(w, r, rpe).joinToString(" ").takeIf { it.isNotEmpty() } + }.joinToString(" | ") + + private fun formatWeight(kg: Double, unit: String): String { + val value = if (unit == "lbs") kg * 2.20462 else kg + return "${value.toInt()} $unit" + } + + private fun pct(value: Float) = "${(value * 100).toInt()}%" + + private fun appendHealthConnect(sb: StringBuilder, health: HealthConnectInput) { + when (health) { + HealthConnectInput.Disabled -> return + HealthConnectInput.Unavailable -> { + sb.appendLine("- Health Connect: not available on this device") + return + } + HealthConnectInput.PermissionMissing -> { + sb.appendLine("- Health Connect: enabled but permissions not granted") + return + } + is HealthConnectInput.Snapshot -> { + sb.appendLine() + sb.appendLine("Health Connect (last 24h / week):") + health.sleepHoursLastNight?.let { + sb.appendLine("- Sleep last night: ${"%.1f".format(it)} hours") + } + health.restingHeartRateBpm?.let { + sb.appendLine("- Resting heart rate: $it bpm") + } + health.stepsToday?.let { + sb.appendLine("- Steps today: $it") + } + health.activeCaloriesToday?.let { + sb.appendLine("- Active calories today: ${it.toInt()} kcal") + } + if (health.cardioSessionsThisWeek > 0) { + sb.appendLine("- Non-strength sessions this week: ${health.cardioSessionsThisWeek}") + } + } + } + } + + private fun trimToTokenBudget(text: String, maxChars: Int): String { + if (text.length <= maxChars) return text + return text.take(maxChars) + "\n…[context truncated for on-device token limit]" + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/ui/viewmodel/AITrainerViewModel.kt b/app/src/main/java/com/jdluu/flexinsight/ui/viewmodel/AITrainerViewModel.kt index 8a6a6f9..d55c0b8 100644 --- a/app/src/main/java/com/jdluu/flexinsight/ui/viewmodel/AITrainerViewModel.kt +++ b/app/src/main/java/com/jdluu/flexinsight/ui/viewmodel/AITrainerViewModel.kt @@ -6,10 +6,10 @@ 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.ai.HevyAiDataAccessor -import com.jdluu.flexinsight.data.preferences.ApiKeyManager +import com.jdluu.flexinsight.data.preferences.ApiKeyStatusSource import com.jdluu.flexinsight.data.repository.FlexRepository -import com.jdluu.flexinsight.data.sync.SyncCoordinator -import com.jdluu.flexinsight.data.sync.SyncManager +import com.jdluu.flexinsight.data.sync.ManualSyncScheduler +import com.jdluu.flexinsight.data.sync.SyncCompleteListener import com.jdluu.flexinsight.domain.usecase.BuildAiContextUseCase import com.jdluu.flexinsight.ui.screens.aitrainer.parts.ChatMessage import kotlinx.coroutines.async @@ -39,9 +39,9 @@ class AITrainerViewModel @Inject constructor( private val aiClient: FlexAIClient, private val buildAiContextUseCase: BuildAiContextUseCase, private val flexRepository: FlexRepository, - private val apiKeyManager: ApiKeyManager, - private val syncManager: SyncManager, - private val syncCoordinator: SyncCoordinator + private val apiKeyManager: ApiKeyStatusSource, + private val syncManager: ManualSyncScheduler, + private val syncCoordinator: SyncCompleteListener ) : ViewModel() { private val _uiState = MutableStateFlow(AITrainerUiState()) diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/ai/PromptBuilderTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/ai/PromptBuilderTest.kt new file mode 100644 index 0000000..e48e862 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/ai/PromptBuilderTest.kt @@ -0,0 +1,506 @@ +package com.jdluu.flexinsight.domain.ai + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Locale +import java.util.TimeZone + +/** + * Characterization tests: the expected strings are pinned byte-for-byte to the + * prompt assembly that previously lived in HevyAiDataAccessor. + */ +class PromptBuilderTest { + + private lateinit var builder: PromptBuilder + private var originalTimeZone: TimeZone? = null + private var originalLocale: Locale? = null + + @Before + fun setUp() { + builder = PromptBuilder() + originalTimeZone = TimeZone.getDefault() + originalLocale = Locale.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + Locale.setDefault(Locale.US) + } + + @After + fun tearDown() { + TimeZone.setDefault(originalTimeZone) + Locale.setDefault(originalLocale) + } + + // region Short-circuit contexts + + @Test + fun `no api key context matches legacy text`() { + val expected = """ + System Context - Hevy Data: + - API key: Not configured. The user must add a Hevy API key in Settings to sync workout history. + - Instruction: Explain that personalized coaching requires connecting Hevy, then offer general fitness guidance only. + """.trimIndent() + assertEquals(expected, builder.noApiKeyContext()) + } + + @Test + fun `zero workout context matches legacy text`() { + val expected = """ + System Context - Hevy Data: + - API key: Connected. + - Workouts synced: 0. Data may still be downloading — suggest the user pull to refresh or open Settings to sync. + - Instruction: Offer general coaching; mention that insights will improve once Hevy workouts sync. + """.trimIndent() + assertEquals(expected, builder.zeroWorkoutContext()) + } + + // endregion + + // region Full training context + + @Test + fun `full context with profile stats recent sessions renders byte identical output`() { + val output = builder.buildTrainingContext(fullMetricInput()) + assertFalse(output.usesLiveExerciseHistory) + assertEquals(FULL_METRIC_EXPECTED, output.text) + } + + @Test + fun `minimal context omits every optional section`() { + val input = PromptBuilder.TrainingContextInput( + workoutCount = 42, + profile = PromptBuilder.ProfileInput( + displayName = "Athlete", + weeklyGoalSessions = 3, + units = "Metric", + memberSinceMillis = null + ) + ) + val output = builder.buildTrainingContext(input) + assertEquals( + MINIMAL_EXPECTED, + output.text + ) + } + + // endregion + + // region Unit formatting + + @Test + fun `metric units render kilos unconverted`() { + val text = builder.buildTrainingContext(fullMetricInput()).text + assertTrue(text.contains("- Total volume (all time): 123456 kg")) + assertTrue(text.contains(" * Bench Press: 100 kg 8r | 102 kg 6r @RPE8.0")) + assertTrue(text.contains("- Bench Press (Chest): 100 kg on 2024-01-01")) + } + + @Test + fun `imperial units convert kilos to pounds`() { + val base = fullMetricInput() + val imperial = base.copy( + profile = base.profile.copy(units = "Imperial"), + exerciseHistoryLookups = listOf( + PromptBuilder.ExerciseHistoryLookup("Bench Press", promptSuccessOutcome()) + ) + ) + val text = builder.buildTrainingContext(imperial).text + assertTrue(text.contains("- Preferred units: Imperial")) + assertTrue(text.contains("- Total volume (all time): 272175 lbs")) + assertTrue(text.contains("- Average session volume: 5171 lbs")) + assertTrue(text.contains(" * Bench Press: 220 lbs 8r | 225 lbs 6r @RPE8.0")) + assertTrue(text.contains("- Bench Press (Chest): 220 lbs on 2024-01-01")) + assertTrue(text.contains(" 2024-01-10: best set 225 lbs x 5 reps e1RM 253 lbs")) + } + + // endregion + + // region Aggregate stats failure isolation + + @Test + fun `partial aggregate stats failure keeps captured pieces`() { + val input = PromptBuilder.TrainingContextInput( + workoutCount = 5, + profile = profileInput(), + trainingSummary = PromptBuilder.TrainingSummary( + base = PromptBuilder.BaseSummary(10.0, 5.0, 1, 2), + fourWeekTrendPctChange = null, + weeklyGoal = null + ) + ) + val text = builder.buildTrainingContext(input).text + assertTrue( + text.contains( + "\nTraining summary:\n" + + "- Total volume (all time): 10 kg\n" + + "- Average session volume: 5 kg\n" + + "- Current streak: 1 days\n" + + "- Longest streak: 2 days\n" + ) + ) + assertFalse(text.contains("4-week volume trend")) + assertFalse(text.contains("This week:")) + } + + // endregion + + // region Exercise history injection + + @Test + fun `exercise history lookups are injected with live api flag`() { + val base = fullMetricInput() + val withLookups = base.copy( + exerciseHistoryLookups = listOf( + PromptBuilder.ExerciseHistoryLookup("Bench Press", promptSuccessOutcome()), + PromptBuilder.ExerciseHistoryLookup( + "Overhead Press", + PromptBuilder.ExerciseHistoryOutcome.Success(entries = emptyList()) + ), + PromptBuilder.ExerciseHistoryLookup( + "Squat", + PromptBuilder.ExerciseHistoryOutcome.Failure(reason = "request timed out") + ), + PromptBuilder.ExerciseHistoryLookup( + "Deadlift", + PromptBuilder.ExerciseHistoryOutcome.Failure(reason = null) + ) + ) + ) + val output = builder.buildTrainingContext(withLookups) + assertTrue(output.usesLiveExerciseHistory) + val expectedSection = listOf( + "Exercise history for this question (from Hevy API):", + "- Bench Press:", + " 2024-01-10: best set 102 kg x 5 reps e1RM 115 kg", + " 2024-01-03: best set 100 kg x 4 reps", + " 2024-01-02: best set —", + "- Overhead Press: no history entries", + "- Squat: history unavailable (request timed out)", + "- Deadlift: history unavailable (null)" + ).joinToString("\n") + assertTrue(output.text.contains(expectedSection)) + } + + @Test + fun `history section placed between latest session and health connect`() { + val input = fullMetricInput().copy( + exerciseHistoryLookups = listOf( + PromptBuilder.ExerciseHistoryLookup("Bench Press", promptSuccessOutcome()) + ), + healthConnect = PromptBuilder.HealthConnectInput.Unavailable + ) + val text = builder.buildTrainingContext(input).text + val latestIndex = text.indexOf("Latest session (Jan 15") + val historyIndex = text.indexOf("Exercise history for this question") + val healthIndex = text.indexOf("- Health Connect: not available on this device") + val instructionIndex = text.lastIndexOf("Instruction: You are an expert") + assertTrue(latestIndex in 0 until historyIndex) + assertTrue(historyIndex < healthIndex && healthIndex < instructionIndex) + } + + @Test + fun `more than five history entries are truncated to five`() { + val entries = (1..7).map { i -> + PromptBuilder.HistoryEntryData( + dateIso = "2024-01-0$i" + "T10:00:00+00:00", + sets = listOf(PromptBuilder.HistorySetData(weightKg = 50.0, reps = i)), + oneRepMaxKg = null + ) + } + val input = fullMetricInput().copy( + exerciseHistoryLookups = listOf( + PromptBuilder.ExerciseHistoryLookup("Bench Press", PromptBuilder.ExerciseHistoryOutcome.Success(entries)) + ) + ) + val text = builder.buildTrainingContext(input).text + assertTrue(text.contains(" 2024-01-05: best set 50 kg x 5 reps")) + assertFalse(text.contains("2024-01-06: best set")) + } + + // endregion + + // region Health Connect tri-states + + @Test + fun `health connect snapshot renders metrics section`() { + val input = fullMetricInput().copy( + healthConnect = PromptBuilder.HealthConnectInput.Snapshot( + sleepHoursLastNight = 7.24, + restingHeartRateBpm = 55L, + stepsToday = 8500L, + activeCaloriesToday = 420.6, + cardioSessionsThisWeek = 2 + ) + ) + val text = builder.buildTrainingContext(input).text + val expected = listOf( + "", + "Health Connect (last 24h / week):", + "- Sleep last night: 7.2 hours", + "- Resting heart rate: 55 bpm", + "- Steps today: 8500", + "- Active calories today: 420 kcal", + "- Non-strength sessions this week: 2" + ).joinToString("\n") + assertTrue(text.contains(expected)) + } + + @Test + fun `health connect permission missing renders single line`() { + val input = fullMetricInput().copy(healthConnect = PromptBuilder.HealthConnectInput.PermissionMissing) + val text = builder.buildTrainingContext(input).text + assertTrue(text.contains("- Health Connect: enabled but permissions not granted")) + assertFalse(text.contains("Health Connect (last 24h / week):")) + } + + @Test + fun `health connect unavailable renders single line`() { + val input = fullMetricInput().copy(healthConnect = PromptBuilder.HealthConnectInput.Unavailable) + val text = builder.buildTrainingContext(input).text + assertTrue(text.contains("- Health Connect: not available on this device")) + assertFalse(text.contains("Health Connect (last 24h / week):")) + } + + @Test + fun `disabled health connect renders nothing`() { + val input = fullMetricInput().copy(healthConnect = PromptBuilder.HealthConnectInput.Disabled) + val text = builder.buildTrainingContext(input).text + assertFalse(text.contains("Health Connect")) + } + + // endregion + + // region Routines cap + + @Test + fun `routines are capped at eight entries`() { + val routines = (1..9).map { i -> + PromptBuilder.RoutineSection("Routine $i", listOf("Squat")) + } + val text = builder.buildTrainingContext(fullMetricInput().copy(routines = routines)).text + assertTrue(text.contains("- Routine 8: Squat")) + assertFalse(text.contains("- Routine 9: Squat")) + } + + // endregion + + // region Truncation + + @Test + fun `oversized context is cut at token budget with truncation marker`() { + val longRoutineName = "R".repeat(13_000) + val input = fullMetricInput().copy( + routines = listOf(PromptBuilder.RoutineSection(longRoutineName, listOf("Squat"))) + ) + val output = builder.buildTrainingContext(input) + val suffix = "\n…[context truncated for on-device token limit]" + assertEquals(12_000 + suffix.length, output.text.length) + assertTrue(output.text.endsWith(suffix)) + assertTrue(output.text.startsWith("System Context - Hevy Training Data")) + } + + @Test + fun `context under budget is never truncated`() { + val output = builder.buildTrainingContext(fullMetricInput()) + assertFalse(output.text.contains("[context truncated")) + assertTrue(output.text.length < 12_000) + } + + // endregion + + // region Query matching + + @Test + fun `query tokens match candidate exercises case insensitively`() { + val candidates = listOf( + PromptBuilder.ExerciseCandidate("Bench Press", "t1"), + PromptBuilder.ExerciseCandidate("Incline Bench Press", "t2"), + PromptBuilder.ExerciseCandidate("Leg Press", "t3"), + PromptBuilder.ExerciseCandidate("Cable Row", "t4"), + PromptBuilder.ExerciseCandidate("Bench Press Variant", "t1") + ) + val matches = builder.matchExerciseTemplates("How is my BENCH press going?", candidates) + assertEquals(listOf("Bench Press", "Incline Bench Press"), matches.map { it.name }) + } + + @Test + fun `short tokens are ignored in matching`() { + val candidates = listOf( + PromptBuilder.ExerciseCandidate("Leg Press", "t3"), + PromptBuilder.ExerciseCandidate("Leg Curl", "t5") + ) + val matches = builder.matchExerciseTemplates("leg day was tough", candidates) + assertTrue(matches.isEmpty()) + } + + @Test + fun `non matching queries return no templates`() { + val candidates = listOf(PromptBuilder.ExerciseCandidate("Bench Press", "t1")) + assertTrue(builder.matchExerciseTemplates("what should I eat today?", candidates).isEmpty()) + } + + // endregion + + // region Fixtures + + private fun profileInput() = PromptBuilder.ProfileInput( + displayName = "Jane Doe", + weeklyGoalSessions = 4, + units = "Metric", + memberSinceMillis = 1_684_281_600_000L + ) + + private fun fullMetricInput(): PromptBuilder.TrainingContextInput { + val recentWorkouts = listOf( + PromptBuilder.RecentWorkout( + startTimeMillis = 1_705_315_200_000L, + name = "Push Day", + exercises = listOf( + PromptBuilder.ExerciseSets( + name = "Bench Press", + sets = listOf( + PromptBuilder.SetData(weightKg = 100.0, reps = 8, rpe = null), + PromptBuilder.SetData(weightKg = 102.5, reps = 6, rpe = 8.0) + ) + ), + PromptBuilder.ExerciseSets(name = "Cable Fly", sets = emptyList()) + ) + ), + PromptBuilder.RecentWorkout( + startTimeMillis = 1_705_228_800_000L, + name = "Pull Day", + exercises = listOf( + PromptBuilder.ExerciseSets( + name = "Barbell Row", + sets = listOf(PromptBuilder.SetData(weightKg = 80.0, reps = 10, rpe = null)) + ) + ) + ) + ) + return PromptBuilder.TrainingContextInput( + workoutCount = 42, + profile = profileInput(), + trainingSummary = PromptBuilder.TrainingSummary( + base = PromptBuilder.BaseSummary( + totalVolumeKg = 123_456.7, + averageSessionVolumeKg = 2_345.9, + currentStreakDays = 3, + longestStreakDays = 10 + ), + fourWeekTrendPctChange = 12.34, + weeklyGoal = PromptBuilder.WeeklyGoalLine(completed = 2, target = 4, status = "On Track") + ), + recentWorkouts = recentWorkouts, + consistencySessionsLast14Days = 9, + personalRecords = listOf( + PromptBuilder.PersonalRecord("Bench Press", "Chest", weightKg = 100.0, dateMillis = 1_704_067_200_000L), + PromptBuilder.PersonalRecord("Squat", "Legs", weightKg = 140.0, dateMillis = 1_703_059_200_000L) + ), + highVolumeMuscles = listOf("Chest", "Back"), + recoveringMuscles = listOf( + PromptBuilder.RecoveringMuscle("LEGS", 0.3f), + PromptBuilder.RecoveringMuscle("ARMS", 0.45f) + ), + volumeBalance = PromptBuilder.VolumeBalanceSplit(push = 0.4f, pull = 0.35f, legs = 0.25f), + routines = listOf( + PromptBuilder.RoutineSection("Push A", listOf("Bench Press", "Incline DB Press")), + PromptBuilder.RoutineSection("Pull A", listOf("Barbell Row")), + PromptBuilder.RoutineSection("Core Circuit", null) + ), + plannedToday = listOf( + PromptBuilder.PlannedWorkoutSection("Leg Day", "High Intensity"), + PromptBuilder.PlannedWorkoutSection("Evening Walk", null) + ), + latestSession = PromptBuilder.LatestSession(1_705_315_200_000L, "Push Day") + ) + } + + private fun promptSuccessOutcome() = PromptBuilder.ExerciseHistoryOutcome.Success( + listOf( + PromptBuilder.HistoryEntryData( + dateIso = "2024-01-10T18:30:00+00:00", + sets = listOf( + PromptBuilder.HistorySetData(weightKg = 102.5, reps = 5), + PromptBuilder.HistorySetData(weightKg = 100.0, reps = 4) + ), + oneRepMaxKg = 115.0 + ), + PromptBuilder.HistoryEntryData( + dateIso = "2024-01-03T17:00:00+00:00", + sets = listOf(PromptBuilder.HistorySetData(weightKg = 100.0, reps = 4)), + oneRepMaxKg = null + ), + PromptBuilder.HistoryEntryData( + dateIso = "2024-01-02T09:00:00+00:00", + sets = null, + oneRepMaxKg = null + ) + ) + ) + + // endregion + + companion object { + /** Byte-pinned expected output for [fullMetricInput]; mirrors legacy accessor formatting. */ + private val FULL_METRIC_EXPECTED = listOf( + "System Context - Hevy Training Data (synced from Hevy API):", + "- Data source: Local cache of Hevy workouts; refreshed on app sync.", + "- Total logged workouts: 42", + "- Name: Jane Doe", + "- Preferred units: Metric", + "- Weekly frequency goal: 4 sessions", + "- Member since: 2023-05-17", + "", + "Training summary:", + "- Total volume (all time): 123456 kg", + "- Average session volume: 2345 kg", + "- Current streak: 3 days", + "- Longest streak: 10 days", + "- 4-week volume trend: 12.3% vs prior period", + "- This week: 2/4 workouts (On Track)", + "", + "Recently completed workouts (last 7 sessions):", + "1. 2024-01-15: Push Day", + " * Bench Press: 100 kg 8r | 102 kg 6r @RPE8.0", + " * Cable Fly: ", + "2. 2024-01-14: Pull Day", + " * Barbell Row: 80 kg 10r", + "- Consistency: 9 workouts in the last 14 days", + "", + "Personal records:", + "- Bench Press (Chest): 100 kg on 2024-01-01", + "- Squat (Legs): 140 kg on 2023-12-20", + "- High recent volume muscles: Chest, Back", + "- Muscles still recovering (<50%): LEGS (30% recovered), ARMS (45% recovered)", + "- Push/Pull/Legs volume split (4 wks): Push 40%, Pull 35%, Legs 25%", + "", + "Saved Hevy routines:", + "- Push A: Bench Press, Incline DB Press", + "- Pull A: Barbell Row", + "- Core Circuit: —", + "", + "Planned for today:", + "- Leg Day (High Intensity)", + "- Evening Walk (planned)", + "", + "Latest session (Jan 15 — Push Day):", + "- Coach note: suggest +2.5% weight or +1 rep on main lifts vs that session when programming progress.", + "", + "Instruction: You are an expert strength coach. Reference the user's actual Hevy numbers, routines, PRs, and recovery state. Be specific — use exercise names, weights, and dates from above. If asked about data not listed, say what you do have and suggest they log it in Hevy." + ).joinToString("\n") + "\n" + + private val MINIMAL_EXPECTED = listOf( + "System Context - Hevy Training Data (synced from Hevy API):", + "- Data source: Local cache of Hevy workouts; refreshed on app sync.", + "- Total logged workouts: 42", + "- Name: Athlete", + "- Preferred units: Metric", + "- Weekly frequency goal: 3 sessions", + "", + "Instruction: You are an expert strength coach. Reference the user's actual Hevy numbers, routines, PRs, and recovery state. Be specific — use exercise names, weights, and dates from above. If asked about data not listed, say what you do have and suggest they log it in Hevy." + ).joinToString("\n") + "\n" + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/fakes/FakeAiClient.kt b/app/src/test/java/com/jdluu/flexinsight/fakes/FakeAiClient.kt new file mode 100644 index 0000000..814605a --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/fakes/FakeAiClient.kt @@ -0,0 +1,56 @@ +package com.jdluu.flexinsight.fakes + +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.ai.AiFeatureStatus +import com.jdluu.flexinsight.data.ai.FlexAIClient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Scriptable [FlexAIClient] double for AI Trainer view-model tests. + * Unlike [FakeFlexAIClient], supports streaming chunks and configurable readiness. + */ +class FakeAiClient : FlexAIClient { + + var featureStatus: AiFeatureStatus = AiFeatureStatus.Ready + var prepareResult: Result = Result.Success(Unit) + var generateResponseResult: Result = Result.Success("Hello!") + + /** Chunks emitted by [generateResponseStream]; a chunk starting with "Error:" mimics failures. */ + var streamChunks: List = listOf("Hi", " there") + + /** When set, the stream throws instead of emitting chunks (exercises VM catch block). */ + var streamError: Exception? = null + + val prompts = mutableListOf() + val streamPrompts = mutableListOf() + val histories = mutableListOf>>() + + override suspend fun isAvailable(): Boolean = + featureStatus == AiFeatureStatus.Ready || featureStatus == AiFeatureStatus.Downloadable + + override suspend fun getFeatureStatus(): AiFeatureStatus = featureStatus + + override suspend fun prepareModel(): Result = prepareResult + + override suspend fun generateResponse( + prompt: String, + history: List> + ): Result { + prompts += prompt + histories += history + return generateResponseResult + } + + override suspend fun generateWorkoutPlan(prompt: String): Result = generateResponseResult + + override fun generateResponseStream( + prompt: String, + history: List> + ): Flow = flow { + streamPrompts += prompt + histories += history + streamError?.let { throw it } + streamChunks.forEach { emit(it) } + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/AITrainerViewModelTest.kt b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/AITrainerViewModelTest.kt new file mode 100644 index 0000000..91ac0c3 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/ui/viewmodel/AITrainerViewModelTest.kt @@ -0,0 +1,314 @@ +package com.jdluu.flexinsight.ui.viewmodel + +import app.cash.turbine.test +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.HevyAiDataAccessor +import com.jdluu.flexinsight.data.preferences.ApiKeyStatusSource +import com.jdluu.flexinsight.data.repository.FlexRepository +import com.jdluu.flexinsight.data.sync.ManualSyncScheduler +import com.jdluu.flexinsight.data.sync.SyncCompleteListener +import com.jdluu.flexinsight.domain.ai.AiContextProvider +import com.jdluu.flexinsight.domain.usecase.BuildAiContextUseCase +import com.jdluu.flexinsight.fakes.FakeAiClient +import com.jdluu.flexinsight.TestApplication +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +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.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 AITrainerViewModelTest { + + private val systemContextText = + "System Context - Hevy Training Data (synced from Hevy API):\n- Total logged workouts: 7" + + private lateinit var aiClient: FakeAiClient + private lateinit var contextProvider: FakeContextProvider + private lateinit var flexRepository: FlexRepository + private lateinit var apiKeySource: FakeApiKeySource + private lateinit var syncScheduler: ManualSyncScheduler + private lateinit var syncListener: SyncCompleteListener + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + + aiClient = FakeAiClient() + contextProvider = FakeContextProvider(defaultSnapshot()) + apiKeySource = FakeApiKeySource(hasKey = false) + flexRepository = mockk(relaxed = true) + coEvery { flexRepository.syncAllData() } returns Result.Error(ApiError.Unknown("offline")) + syncScheduler = mockk() + every { syncScheduler.syncNow() } returns Unit + syncListener = mockk() + coEvery { syncListener.onSyncComplete() } returns Unit + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + // region Initialization / readiness + + @Test + fun `ready status prepares model and greets without syncing when api key missing`() = runTest { + // Snapshot must mirror the no-key state so the greeting prompt takes the + // "not connected" branch; the shared default fixture has a connected key. + contextProvider.queued += defaultSnapshot().copy(hasWorkoutData = false, hasApiKey = false, workoutCount = 0) + val viewModel = buildViewModel() + + val state = viewModel.uiState.value + assertTrue(state.isAiAvailable) + assertFalse(state.isPreparingModel) + assertFalse(state.isSyncingHevyData) + assertEquals(1, state.messages.size) + val greeting = state.messages.single() + assertEquals("ai", greeting.sender) + assertEquals("Hello!", greeting.text) + + val prompt = aiClient.prompts.single() + assertTrue(prompt.startsWith(systemContextText)) + assertTrue(prompt.contains("The user has not connected Hevy yet")) + + coVerify(exactly = 0) { flexRepository.syncAllData() } + } + + @Test + fun `ready status syncs hevy data before greeting when api key present`() = runTest { + apiKeySource.hasKey = true + val viewModel = buildViewModel() + + val state = viewModel.uiState.value + assertTrue(state.hasHevyData) + assertEquals(7, state.hevyWorkoutCount) + assertFalse(state.isSyncingHevyData) + assertEquals(1, state.messages.size) + + val prompt = aiClient.prompts.single() + assertTrue(prompt.contains("Based on this user's real Hevy training data")) + } + + @Test + fun `prepare failure surfaces error bubble and disables availability`() = runTest { + aiClient.prepareResult = Result.Error(ApiError.Unknown("model download failed")) + val viewModel = buildViewModel() + + val state = viewModel.uiState.value + assertFalse(state.isAiAvailable) + assertFalse(state.isPreparingModel) + assertEquals("model download failed", state.aiStatusMessage) + assertEquals("ai", state.messages.single().sender) + assertEquals("model download failed", state.messages.single().text) + } + + @Test + fun `downloading status keeps preparing and informs user`() = runTest { + aiClient.featureStatus = AiFeatureStatus.Downloading + val viewModel = buildViewModel() + + val state = viewModel.uiState.value + assertFalse(state.isAiAvailable) + assertTrue(state.isPreparingModel) + assertEquals( + "Gemini Nano is downloading. This may take a few minutes on first use.", + state.aiStatusMessage + ) + assertEquals(state.aiStatusMessage, state.messages.single().text) + } + + @Test + fun `unavailable status disables chat and blocks sending`() = runTest { + aiClient.featureStatus = AiFeatureStatus.Unavailable + val viewModel = buildViewModel() + + val initialState = viewModel.uiState.value + assertFalse(initialState.isAiAvailable) + assertFalse(initialState.isPreparingModel) + assertTrue( + initialState.aiStatusMessage!!.startsWith("On-device AI isn't available on this device.") + ) + assertEquals(initialState.aiStatusMessage, initialState.messages.single().text) + + viewModel.sendMessage("hi") + + assertEquals(1, viewModel.uiState.value.messages.size) + assertTrue(aiClient.streamPrompts.isEmpty()) + } + + // endregion + + // region sendMessage streaming + + @Test + fun `sendMessage streams chunks into assistant reply with system context history`() = runTest { + val viewModel = buildViewModel() + aiClient.streamChunks = listOf("Bench ", "progress ", "looks great") + contextProvider.usesLiveForQueries = true + + viewModel.sendMessage("How's my bench?") + + val state = viewModel.uiState.value + assertEquals(3, state.messages.size) + assertEquals("user", state.messages[1].sender) + assertEquals("How's my bench?", state.messages[1].text) + assertEquals("ai", state.messages[2].sender) + assertEquals("Bench progress looks great", state.messages[2].text) + assertFalse(state.isTyping) + assertNull(state.error) + assertTrue(state.usesLiveExerciseHistory) + assertEquals("How's my bench?", aiClient.streamPrompts.single()) + assertEquals("How's my bench?", contextProvider.queries.last()) + + val history = aiClient.histories.last() // greeting used generateResponse; send used the stream + assertEquals("user", history.first().first) + assertTrue(history.first().second.startsWith("System Context:\n$systemContextText")) + assertEquals( + "model" to "Understood. I will coach using only this Hevy data and say when something is not available.", + history[1] + ) + } + + @Test + fun `error chunk from stream sets error state without adding reply`() = runTest { + val viewModel = buildViewModel() + aiClient.streamChunks = listOf("Error: on-device model busy") + + viewModel.sendMessage("hello?") + + val state = viewModel.uiState.value + assertEquals("Error: on-device model busy", state.error) + assertFalse(state.isTyping) + assertEquals(2, state.messages.size) // greeting + user message only + } + + @Test + fun `stream exception surfaces fallback assistant message`() = runTest { + val viewModel = buildViewModel() + aiClient.streamError = IllegalStateException("boom") + + viewModel.sendMessage("hello?") + + val state = viewModel.uiState.value + assertEquals(3, state.messages.size) + assertEquals("I'm having trouble thinking right now. Please try again.", state.messages[2].text) + assertFalse(state.isTyping) + } + + @Test + fun `blank messages are ignored`() = runTest { + val viewModel = buildViewModel() + + viewModel.sendMessage(" ") + + assertEquals(1, viewModel.uiState.value.messages.size) + assertTrue(aiClient.streamPrompts.isEmpty()) + } + + // endregion + + // region refreshHevySync (Turbine state emissions) + + @Test + fun `refreshHevySync emits syncing then settled state and notifies listener`() = runTest { + val viewModel = buildViewModel() + apiKeySource.hasKey = true + contextProvider.queued += defaultSnapshot().copy(workoutCount = 9) + + val gate = CompletableDeferred() + coEvery { flexRepository.syncAllData() } coAnswers { gate.await(); Result.Success(Unit) } + + viewModel.refreshHevySync() + + viewModel.uiState.test { + val syncing = awaitItem() + assertTrue(syncing.isSyncingHevyData) + + gate.complete(Unit) + val settled = awaitItem() + assertFalse(settled.isSyncingHevyData) + assertEquals(9, settled.hevyWorkoutCount) + assertTrue(settled.hasHevyData) + } + + verify(exactly = 1) { syncScheduler.syncNow() } + coVerify(exactly = 1) { syncListener.onSyncComplete() } + assertNull(contextProvider.queries.last()) // manual refresh rebuilds context without a query + } + + @Test + fun `refreshHevySync skips when api key missing`() = runTest { + val viewModel = buildViewModel() + + viewModel.refreshHevySync() + + verify(exactly = 0) { syncScheduler.syncNow() } + coVerify(exactly = 0) { flexRepository.syncAllData() } + } + + // endregion + + // region Fixtures + + private fun defaultSnapshot() = HevyAiDataAccessor.ContextSnapshot( + text = systemContextText, + hasWorkoutData = true, + hasApiKey = true, + workoutCount = 7, + usesLiveExerciseHistory = false + ) + + private fun buildViewModel(): AITrainerViewModel = AITrainerViewModel( + aiClient = aiClient, + buildAiContextUseCase = BuildAiContextUseCase(contextProvider), + flexRepository = flexRepository, + apiKeyManager = apiKeySource, + syncManager = syncScheduler, + syncCoordinator = syncListener + ) + + private class FakeApiKeySource(var hasKey: Boolean) : ApiKeyStatusSource { + override suspend fun hasApiKey(): Boolean = hasKey + } + + /** Returns queued snapshots in order, then repeats [base]; flags live history for real queries. */ + private class FakeContextProvider( + private val base: HevyAiDataAccessor.ContextSnapshot + ) : AiContextProvider { + val queries = mutableListOf() + val queued = ArrayDeque() + var usesLiveForQueries = false + + override suspend fun buildContext(userQuery: String?): HevyAiDataAccessor.ContextSnapshot { + queries += userQuery + val next = queued.removeFirstOrNull() ?: base + return next.copy(usesLiveExerciseHistory = userQuery != null && usesLiveForQueries) + } + } + + // endregion +}