From fcdc7bba9fbaf6a11ea60e7fbb274952f20cbb8a Mon Sep 17 00:00:00 2001 From: Devil Date: Sat, 25 Jul 2026 13:26:29 -0400 Subject: [PATCH 1/7] fix: use effectiveHeaviestKgPerCable() for bodyweight exercise display and 1RM ExerciseDetailScreen was reading weightPerCableKg (configured/nominal load) for weight trend, history table, and session history row. For bodyweight exercises, this field contains the nominal cable value (e.g. 11.0 lb) instead of the bodyweight-derived effective load that was correctly persisted during the workout session. The 1RM helper (estimatedOneRepMaxPerCableOrNull) had the same issue, producing 1RM estimates from the nominal load rather than the effective load. Fix: switch all 4 display/read paths to use effectiveHeaviestKgPerCable(), which prefers the persisted heaviestLiftKg and falls back to weightPerCableKg for legacy sessions without that field. Closes #684 --- .../usecase/ResolveCurrentOneRepMaxUseCase.kt | 3 +- .../screen/ExerciseDetailScreen.kt | 9 +++--- .../ResolveCurrentOneRepMaxUseCaseTest.kt | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt index 8f01c4424..e1a06f463 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt @@ -5,6 +5,7 @@ import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository import com.devil.phoenixproject.data.repository.WorkoutRepository import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.model.effectiveHeaviestKgPerCable import com.devil.phoenixproject.util.OneRepMaxCalculator enum class CurrentOneRepMaxSource { @@ -20,7 +21,7 @@ data class CurrentOneRepMax( ) fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(): Float? { - val load = weightPerCableKg.takeIf { it.isFinite() && it > 0f } ?: return null + val load = effectiveHeaviestKgPerCable().takeIf { it.isFinite() && it > 0f } ?: return null val reps = workingReps.takeIf { it > 0 } ?: totalReps.takeIf { it > 0 } ?: return null diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt index 36593e8f4..6cf636581 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt @@ -29,6 +29,7 @@ import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.WeightUnit import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.model.effectiveHeaviestKgPerCable import com.devil.phoenixproject.domain.model.effectiveTotalVolumeKg import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax import com.devil.phoenixproject.domain.usecase.CurrentOneRepMaxSource @@ -190,8 +191,8 @@ fun ExerciseDetailScreen( // Weight-over-time trend data using saved per-cable load. val weightTrendData = remember(exerciseSessions) { exerciseSessions.mapNotNull { session -> - if (session.weightPerCableKg > 0) { - session.timestamp to session.weightPerCableKg + if (session.effectiveHeaviestKgPerCable() > 0) { + session.timestamp to session.effectiveHeaviestKgPerCable() } else { null } @@ -737,7 +738,7 @@ private fun ExerciseHistoryTable(sessions: List, weightUnit: Wei ) TableCell( WeightDisplayFormatter.formatDisplayWeight( - session.weightPerCableKg, + session.effectiveHeaviestKgPerCable(), null, weightUnit, ), @@ -823,7 +824,7 @@ private fun SessionHistoryRow(session: WorkoutSession, weightUnit: WeightUnit, f color = MaterialTheme.colorScheme.onSurface, ) Text( - "${WeightDisplayFormatter.formatDisplayWeight(session.weightPerCableKg, null, weightUnit)} × ${session.workingReps} reps", + "${WeightDisplayFormatter.formatDisplayWeight(session.effectiveHeaviestKgPerCable(), null, weightUnit)} × ${session.workingReps} reps", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt index 18fcfeeaf..9588e9255 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt @@ -122,6 +122,36 @@ class ResolveCurrentOneRepMaxUseCaseTest { assertNull(base.copy(workingReps = -1, totalReps = -1).estimatedOneRepMaxPerCableOrNull()) } + @Test + fun `session helper uses effectiveHeaviestKgPerCable for bodyweight sessions`() { + // Bodyweight session: configured weight is 0 but effective heaviest is 40 kg + val bodyweightSession = session( + perCableKg = 0f, + workingReps = 10, + totalReps = 10, + heaviestLiftKg = 40f, + ) + val result = bodyweightSession.estimatedOneRepMaxPerCableOrNull() + assertEquals(53.3f, result!!, 0.2f) // 1RM from 40kg x 10 reps ≈ 53.3 + // The old code would return null here since weightPerCableKg=0 + + // Nominal configured load but effective heaviest is higher (weighted vest + bodyweight) + val vestSession = session( + perCableKg = 5f, + workingReps = 10, + totalReps = 10, + heaviestLiftKg = 40f, + ) + val vestResult = vestSession.estimatedOneRepMaxPerCableOrNull() + assertEquals(53.3f, vestResult!!, 0.2f) + // Old code would compute from 5f → ~6.7f, not 40f + + // Legacy session with no heaviestLiftKg falls back to configured weight + val legacySession = session(perCableKg = 50f, workingReps = 5, totalReps = 5) + val legacyResult = legacySession.estimatedOneRepMaxPerCableOrNull() + assertEquals(56.25f, legacyResult!!, 0.1f) // 1RM from 50kg x 5 reps + } + @Test fun `wrong profile and exercise at higher sources cannot block current session`() = runTest { velocity.latestPassing = velocityEstimate( @@ -249,6 +279,7 @@ class ResolveCurrentOneRepMaxUseCaseTest { profileId: String = "athlete-a", exerciseId: String = "bench", timestamp: Long = 10L, + heaviestLiftKg: Float? = null, ) = WorkoutSession( id = "$profileId-$exerciseId-$timestamp-$perCableKg", timestamp = timestamp, @@ -261,6 +292,7 @@ class ResolveCurrentOneRepMaxUseCaseTest { exerciseId = exerciseId, exerciseName = exerciseId, profileId = profileId, + heaviestLiftKg = heaviestLiftKg, ) private fun velocityEstimate( From 49a31e32c8a3b8ac5b5a64273424be989ecead87 Mon Sep 17 00:00:00 2001 From: Devil Date: Sat, 25 Jul 2026 13:45:01 -0400 Subject: [PATCH 2/7] fix: fall back to weightPerCableKg when heaviestLiftKg is zero Address Codex P2 review: when heaviestLiftKg=0f (measured zero but summary metrics exist), effectiveHeaviestKgPerCable() returns 0 instead of falling back to weightPerCableKg. This mirrors the SQL selectExerciseWeightHistory behavior that treats nonpositive heaviestLiftKg as absent. Added test case covering heaviestLiftKg=0f edge case. --- .../domain/usecase/ResolveCurrentOneRepMaxUseCase.kt | 8 +++++++- .../usecase/ResolveCurrentOneRepMaxUseCaseTest.kt | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt index e1a06f463..e6a4a5464 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt @@ -21,7 +21,13 @@ data class CurrentOneRepMax( ) fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(): Float? { - val load = effectiveHeaviestKgPerCable().takeIf { it.isFinite() && it > 0f } ?: return null + // effectiveHeaviestKgPerCable() returns heaviestLiftKg ?: weightPerCableKg, + // but heaviestLiftKg = 0f (measured zero) is treated as "no data" here, + // matching the SQL selectExerciseWeightHistory fallback that treats + // nonpositive heaviestLiftKg as absent. + val load = effectiveHeaviestKgPerCable().takeIf { it.isFinite() && it > 0f } + ?: weightPerCableKg.takeIf { it.isFinite() && it > 0f } + ?: return null val reps = workingReps.takeIf { it > 0 } ?: totalReps.takeIf { it > 0 } ?: return null diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt index 9588e9255..577ee3b1d 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt @@ -150,6 +150,17 @@ class ResolveCurrentOneRepMaxUseCaseTest { val legacySession = session(perCableKg = 50f, workingReps = 5, totalReps = 5) val legacyResult = legacySession.estimatedOneRepMaxPerCableOrNull() assertEquals(56.25f, legacyResult!!, 0.1f) // 1RM from 50kg x 5 reps + + // Edge case: heaviestLiftKg = 0f (measured zero) should fall back to weightPerCableKg + // matching SQL selectExerciseWeightHistory that treats nonpositive heaviestLiftKg as absent + val zeroMeasuredSession = session( + perCableKg = 50f, + workingReps = 5, + totalReps = 5, + heaviestLiftKg = 0f, + ) + val zeroMeasuredResult = zeroMeasuredSession.estimatedOneRepMaxPerCableOrNull() + assertEquals(56.25f, zeroMeasuredResult!!, 0.1f) // falls back to weightPerCableKg=50 } @Test From 41f6d847c1380bb0298e86825baecfd0e157ecf3 Mon Sep 17 00:00:00 2001 From: Devil Date: Sat, 25 Jul 2026 14:01:21 -0400 Subject: [PATCH 3/7] fix: apply positive/finite fallback in display paths for heaviestLiftKg=0f MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex P2 review on ExerciseDetailScreen: added displayHeaviestKgPerCable() helper that falls back to weightPerCableKg when effectiveHeaviestKgPerCable() returns 0 (from heaviestLiftKg=0f). Applied to weight trend chart, history table weight column, and session history row — matching the SQL selectExerciseWeightHistory fallback. --- .../presentation/screen/ExerciseDetailScreen.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt index 6cf636581..c97074965 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt @@ -188,11 +188,19 @@ fun ExerciseDetailScreen( val previousSessionOneRepMax = validSessionEstimatesNewestFirst.getOrNull(1)?.second + // Display-safe heaviest load: fall back to weightPerCableKg when + // heaviestLiftKg is 0f (measured zero), matching SQL behavior. + fun WorkoutSession.displayHeaviestKgPerCable(): Float = + effectiveHeaviestKgPerCable().takeIf { it > 0f } + ?: weightPerCableKg.takeIf { it > 0f } + ?: 0f + // Weight-over-time trend data using saved per-cable load. val weightTrendData = remember(exerciseSessions) { exerciseSessions.mapNotNull { session -> - if (session.effectiveHeaviestKgPerCable() > 0) { - session.timestamp to session.effectiveHeaviestKgPerCable() + val load = session.displayHeaviestKgPerCable() + if (load > 0) { + session.timestamp to load } else { null } @@ -738,7 +746,7 @@ private fun ExerciseHistoryTable(sessions: List, weightUnit: Wei ) TableCell( WeightDisplayFormatter.formatDisplayWeight( - session.effectiveHeaviestKgPerCable(), + session.displayHeaviestKgPerCable(), null, weightUnit, ), @@ -824,7 +832,7 @@ private fun SessionHistoryRow(session: WorkoutSession, weightUnit: WeightUnit, f color = MaterialTheme.colorScheme.onSurface, ) Text( - "${WeightDisplayFormatter.formatDisplayWeight(session.effectiveHeaviestKgPerCable(), null, weightUnit)} × ${session.workingReps} reps", + "${WeightDisplayFormatter.formatDisplayWeight(session.displayHeaviestKgPerCable(), null, weightUnit)} × ${session.workingReps} reps", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) From a5ada65f569a48599910ae7c03b1f145a6dbf18e Mon Sep 17 00:00:00 2001 From: Devil Date: Sat, 25 Jul 2026 14:10:15 -0400 Subject: [PATCH 4/7] fix: move displayHeaviestKgPerCable to top-level private function Local extension function inside composable was unreachable from nested composable lambdas (Compose compiler transform). Moved to top-level private extension function at file bottom. --- .../presentation/screen/ExerciseDetailScreen.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt index c97074965..2c7ad0ccd 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt @@ -188,13 +188,6 @@ fun ExerciseDetailScreen( val previousSessionOneRepMax = validSessionEstimatesNewestFirst.getOrNull(1)?.second - // Display-safe heaviest load: fall back to weightPerCableKg when - // heaviestLiftKg is 0f (measured zero), matching SQL behavior. - fun WorkoutSession.displayHeaviestKgPerCable(): Float = - effectiveHeaviestKgPerCable().takeIf { it > 0f } - ?: weightPerCableKg.takeIf { it > 0f } - ?: 0f - // Weight-over-time trend data using saved per-cable load. val weightTrendData = remember(exerciseSessions) { exerciseSessions.mapNotNull { session -> @@ -896,3 +889,13 @@ private fun formatDuration(durationMs: Long): String { val minutes = durationMs / 60000 return if (minutes > 0) "${minutes}min" else "<1min" } + +/** + * Display-safe heaviest load per cable: falls back to weightPerCableKg when + * heaviestLiftKg is 0f (measured zero), matching SQL selectExerciseWeightHistory + * behavior that treats nonpositive heaviestLiftKg as absent. + */ +private fun WorkoutSession.displayHeaviestKgPerCable(): Float = + effectiveHeaviestKgPerCable().takeIf { it > 0f } + ?: weightPerCableKg.takeIf { it > 0f } + ?: 0f From 092d3ccebac0ed17320c529e32a6dafc898c771a Mon Sep 17 00:00:00 2001 From: Devil Date: Sat, 25 Jul 2026 14:35:36 -0400 Subject: [PATCH 5/7] fix: preserve zero bodyweight record load --- .../phoenixproject/domain/model/Models.kt | 18 ++++++++++++++++++ .../usecase/ResolveCurrentOneRepMaxUseCase.kt | 10 ++-------- .../screen/ExerciseDetailScreen.kt | 12 +----------- .../domain/model/WorkoutMetricTest.kt | 16 ++++++++++++++++ .../ResolveCurrentOneRepMaxUseCaseTest.kt | 6 ++++++ 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 94d8b1130..670363549 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -608,6 +608,24 @@ data class WorkoutSession( */ fun WorkoutSession.effectiveHeaviestKgPerCable(): Float = heaviestLiftKg ?: weightPerCableKg +/** + * Resolves the safe per-cable load for history, progression, and 1RM display. + * + * A finite positive recorded summary value is authoritative. A zero recorded value is + * preserved only when persisted counterweight context proves that a bodyweight load was + * genuinely offset to zero; otherwise zero, negative, and non-finite summary values fall + * back to the finite positive configured value. Legacy sessions have no summary value and + * therefore use their configured weight unchanged. + */ +fun WorkoutSession.displayHeaviestKgPerCable(): Float { + val measured = heaviestLiftKg + if (measured != null && measured.isFinite()) { + if (measured > 0f) return measured + if (measured == 0f && counterweightKg.isFinite() && counterweightKg > 0f) return 0f + } + return weightPerCableKg.takeIf { it.isFinite() && it > 0f } ?: 0f +} + /** * Legacy multiplier metadata for explicit total/compatibility paths. * diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt index e6a4a5464..32a8c424b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt @@ -5,7 +5,7 @@ import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository import com.devil.phoenixproject.data.repository.WorkoutRepository import com.devil.phoenixproject.domain.model.WorkoutSession -import com.devil.phoenixproject.domain.model.effectiveHeaviestKgPerCable +import com.devil.phoenixproject.domain.model.displayHeaviestKgPerCable import com.devil.phoenixproject.util.OneRepMaxCalculator enum class CurrentOneRepMaxSource { @@ -21,13 +21,7 @@ data class CurrentOneRepMax( ) fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(): Float? { - // effectiveHeaviestKgPerCable() returns heaviestLiftKg ?: weightPerCableKg, - // but heaviestLiftKg = 0f (measured zero) is treated as "no data" here, - // matching the SQL selectExerciseWeightHistory fallback that treats - // nonpositive heaviestLiftKg as absent. - val load = effectiveHeaviestKgPerCable().takeIf { it.isFinite() && it > 0f } - ?: weightPerCableKg.takeIf { it.isFinite() && it > 0f } - ?: return null + val load = displayHeaviestKgPerCable().takeIf { it > 0f } ?: return null val reps = workingReps.takeIf { it > 0 } ?: totalReps.takeIf { it > 0 } ?: return null diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt index 2c7ad0ccd..f76a1dcea 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt @@ -29,7 +29,7 @@ import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.WeightUnit import com.devil.phoenixproject.domain.model.WorkoutSession -import com.devil.phoenixproject.domain.model.effectiveHeaviestKgPerCable +import com.devil.phoenixproject.domain.model.displayHeaviestKgPerCable import com.devil.phoenixproject.domain.model.effectiveTotalVolumeKg import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax import com.devil.phoenixproject.domain.usecase.CurrentOneRepMaxSource @@ -889,13 +889,3 @@ private fun formatDuration(durationMs: Long): String { val minutes = durationMs / 60000 return if (minutes > 0) "${minutes}min" else "<1min" } - -/** - * Display-safe heaviest load per cable: falls back to weightPerCableKg when - * heaviestLiftKg is 0f (measured zero), matching SQL selectExerciseWeightHistory - * behavior that treats nonpositive heaviestLiftKg as absent. - */ -private fun WorkoutSession.displayHeaviestKgPerCable(): Float = - effectiveHeaviestKgPerCable().takeIf { it > 0f } - ?: weightPerCableKg.takeIf { it > 0f } - ?: 0f diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt index 9545b78a1..b640e9ecb 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt @@ -216,6 +216,22 @@ class WorkoutSessionTest { assertEquals(30f, session.effectiveHeaviestKgPerCable()) } + @Test + fun `display heaviest load preserves counterweighted zero and rejects invalid summary values`() { + val counterweightedSession = WorkoutSession( + weightPerCableKg = 30f, + heaviestLiftKg = 0f, + counterweightKg = 80f, + ) + val nonFiniteMeasuredSession = WorkoutSession( + weightPerCableKg = 30f, + heaviestLiftKg = Float.POSITIVE_INFINITY, + ) + + assertEquals(0f, counterweightedSession.displayHeaviestKgPerCable()) + assertEquals(30f, nonFiniteMeasuredSession.displayHeaviestKgPerCable()) + } + @Test fun `effectiveTotalVolumeKg uses measured summary value when present`() { val session = WorkoutSession( diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt index 577ee3b1d..462225775 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt @@ -161,6 +161,12 @@ class ResolveCurrentOneRepMaxUseCaseTest { ) val zeroMeasuredResult = zeroMeasuredSession.estimatedOneRepMaxPerCableOrNull() assertEquals(56.25f, zeroMeasuredResult!!, 0.1f) // falls back to weightPerCableKg=50 + + // A bodyweight session can legitimately persist zero effective load when an + // assistance counterweight offsets the bodyweight-derived load. It must not + // fabricate a 1RM from the unrelated configured machine load. + val counterweightedBodyweightSession = zeroMeasuredSession.copy(counterweightKg = 100f) + assertNull(counterweightedBodyweightSession.estimatedOneRepMaxPerCableOrNull()) } @Test From a27d32db4e53e4785f1a9f4f4d7a218a949da550 Mon Sep 17 00:00:00 2001 From: Devil Date: Sat, 25 Jul 2026 15:03:36 -0400 Subject: [PATCH 6/7] fix: fall back from ambiguous zero record loads --- .../devil/phoenixproject/domain/model/Models.kt | 15 ++++++--------- .../domain/model/WorkoutMetricTest.kt | 6 ++++-- .../usecase/ResolveCurrentOneRepMaxUseCaseTest.kt | 8 ++++---- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 670363549..1bd65b9ae 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -611,18 +611,15 @@ fun WorkoutSession.effectiveHeaviestKgPerCable(): Float = heaviestLiftKg ?: weig /** * Resolves the safe per-cable load for history, progression, and 1RM display. * - * A finite positive recorded summary value is authoritative. A zero recorded value is - * preserved only when persisted counterweight context proves that a bodyweight load was - * genuinely offset to zero; otherwise zero, negative, and non-finite summary values fall - * back to the finite positive configured value. Legacy sessions have no summary value and - * therefore use their configured weight unchanged. + * A finite positive recorded summary value is authoritative. Zero, negative, and non-finite + * summary values fall back to the finite positive configured value. A WorkoutSession does not + * persist the exercise bodyweight classification, so counterweight alone cannot safely identify + * an intentional zero bodyweight load. Legacy sessions have no summary value and therefore use + * their configured weight unchanged. */ fun WorkoutSession.displayHeaviestKgPerCable(): Float { val measured = heaviestLiftKg - if (measured != null && measured.isFinite()) { - if (measured > 0f) return measured - if (measured == 0f && counterweightKg.isFinite() && counterweightKg > 0f) return 0f - } + if (measured != null && measured.isFinite() && measured > 0f) return measured return weightPerCableKg.takeIf { it.isFinite() && it > 0f } ?: 0f } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt index b640e9ecb..ba88bdc49 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt @@ -217,7 +217,7 @@ class WorkoutSessionTest { } @Test - fun `display heaviest load preserves counterweighted zero and rejects invalid summary values`() { + fun `display heaviest load falls back from zero and invalid summary values`() { val counterweightedSession = WorkoutSession( weightPerCableKg = 30f, heaviestLiftKg = 0f, @@ -228,7 +228,9 @@ class WorkoutSessionTest { heaviestLiftKg = Float.POSITIVE_INFINITY, ) - assertEquals(0f, counterweightedSession.displayHeaviestKgPerCable()) + // WorkoutSession does not persist Exercise.isBodyweight, so rack counterweight alone + // cannot prove this zero was an intentional bodyweight effective load. + assertEquals(30f, counterweightedSession.displayHeaviestKgPerCable()) assertEquals(30f, nonFiniteMeasuredSession.displayHeaviestKgPerCable()) } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt index 462225775..0f441ace6 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt @@ -162,11 +162,11 @@ class ResolveCurrentOneRepMaxUseCaseTest { val zeroMeasuredResult = zeroMeasuredSession.estimatedOneRepMaxPerCableOrNull() assertEquals(56.25f, zeroMeasuredResult!!, 0.1f) // falls back to weightPerCableKg=50 - // A bodyweight session can legitimately persist zero effective load when an - // assistance counterweight offsets the bodyweight-derived load. It must not - // fabricate a 1RM from the unrelated configured machine load. + // WorkoutSession does not persist bodyweight classification, so counterweight alone + // cannot distinguish a legitimate zero bodyweight load from an invalid machine metric. + // Preserve the canonical configured-load fallback used by history SQL. val counterweightedBodyweightSession = zeroMeasuredSession.copy(counterweightKg = 100f) - assertNull(counterweightedBodyweightSession.estimatedOneRepMaxPerCableOrNull()) + assertEquals(56.25f, counterweightedBodyweightSession.estimatedOneRepMaxPerCableOrNull()!!, 0.1f) } @Test From 766c25e1e97e840a597599e11a8c7cdfc07113aa Mon Sep 17 00:00:00 2001 From: Devil Date: Sat, 25 Jul 2026 15:15:18 -0400 Subject: [PATCH 7/7] fix: preserve confirmed bodyweight zero loads --- .../phoenixproject/domain/model/Models.kt | 23 +++++++--- .../usecase/ResolveCurrentOneRepMaxUseCase.kt | 12 ++++-- .../screen/ExerciseDetailScreen.kt | 42 +++++++++++++------ .../domain/model/WorkoutMetricTest.kt | 7 ++-- .../ResolveCurrentOneRepMaxUseCaseTest.kt | 12 ++++-- .../screen/ExerciseDetailOneRepMaxLoadTest.kt | 32 ++++++++++---- 6 files changed, 91 insertions(+), 37 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 1bd65b9ae..7234326af 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -612,14 +612,25 @@ fun WorkoutSession.effectiveHeaviestKgPerCable(): Float = heaviestLiftKg ?: weig * Resolves the safe per-cable load for history, progression, and 1RM display. * * A finite positive recorded summary value is authoritative. Zero, negative, and non-finite - * summary values fall back to the finite positive configured value. A WorkoutSession does not - * persist the exercise bodyweight classification, so counterweight alone cannot safely identify - * an intentional zero bodyweight load. Legacy sessions have no summary value and therefore use - * their configured weight unchanged. + * summary values fall back to the finite positive configured value. A deliberate zero is + * preserved only when the caller supplies the canonical bodyweight classification and the + * persisted rack context contains a positive counterweight. A WorkoutSession alone does not + * persist that classification, so counterweight must never be treated as proof. Legacy sessions + * have no summary value and therefore use their configured weight unchanged. */ -fun WorkoutSession.displayHeaviestKgPerCable(): Float { +fun WorkoutSession.displayHeaviestKgPerCable(isBodyweight: Boolean = false): Float { val measured = heaviestLiftKg - if (measured != null && measured.isFinite() && measured > 0f) return measured + if (measured != null && measured.isFinite()) { + if (measured > 0f) return measured + if ( + measured == 0f && + isBodyweight && + counterweightKg.isFinite() && + counterweightKg > 0f + ) { + return 0f + } + } return weightPerCableKg.takeIf { it.isFinite() && it > 0f } ?: 0f } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt index 32a8c424b..d32a926de 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt @@ -20,8 +20,8 @@ data class CurrentOneRepMax( val measuredAt: Long, ) -fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(): Float? { - val load = displayHeaviestKgPerCable().takeIf { it > 0f } ?: return null +fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(isBodyweight: Boolean = false): Float? { + val load = displayHeaviestKgPerCable(isBodyweight).takeIf { it > 0f } ?: return null val reps = workingReps.takeIf { it > 0 } ?: totalReps.takeIf { it > 0 } ?: return null @@ -37,7 +37,11 @@ class ResolveCurrentOneRepMaxUseCase( private val assessmentRepository: AssessmentRepository, private val workoutRepository: WorkoutRepository, ) { - suspend operator fun invoke(exerciseId: String, profileId: String): CurrentOneRepMax? { + suspend operator fun invoke( + exerciseId: String, + profileId: String, + isBodyweight: Boolean = false, + ): CurrentOneRepMax? { require(exerciseId.isNotBlank()) require(profileId.isNotBlank()) @@ -80,7 +84,7 @@ class ResolveCurrentOneRepMaxUseCase( limit = MAX_RECENT_EXERCISE_SESSIONS, ).forEach { session -> if (session.exerciseId == exerciseId && session.profileId == profileId) { - val estimate = session.estimatedOneRepMaxPerCableOrNull() + val estimate = session.estimatedOneRepMaxPerCableOrNull(isBodyweight) if (estimate != null) { return CurrentOneRepMax( perCableKg = estimate, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt index f76a1dcea..ba5046d95 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt @@ -56,6 +56,7 @@ internal data class ExerciseDetailOneRepMaxRequest( val exerciseId: String, val profileId: String, val completedSessions: List, + val isBodyweight: Boolean = false, ) internal data class ExerciseDetailOneRepMaxLoadToken( @@ -82,13 +83,13 @@ internal sealed interface ExerciseDetailOneRepMaxState { internal suspend fun loadExerciseDetailOneRepMax( request: ExerciseDetailOneRepMaxRequest, gate: ExerciseDetailOneRepMaxLoadGate, - resolve: suspend (exerciseId: String, profileId: String) -> CurrentOneRepMax?, + resolve: suspend (exerciseId: String, profileId: String, isBodyweight: Boolean) -> CurrentOneRepMax?, publish: (ExerciseDetailOneRepMaxState) -> Unit, ) { val token = gate.begin(request) publish(ExerciseDetailOneRepMaxState.Loading) try { - val resolution = resolve(request.exerciseId, request.profileId) + val resolution = resolve(request.exerciseId, request.profileId, request.isBodyweight) if (gate.isCurrent(token)) { publish(ExerciseDetailOneRepMaxState.Ready(resolution)) } @@ -140,22 +141,25 @@ fun ExerciseDetailScreen( // Get exercise name — null while the repository call is in flight val unknownExerciseLabel = stringResource(Res.string.exercise_unknown) - var exerciseName by remember { mutableStateOf(null) } + var exerciseName by remember(exerciseId) { mutableStateOf(null) } + var exerciseIsBodyweight by remember(exerciseId) { mutableStateOf(false) } LaunchedEffect(exerciseId) { val exercise = viewModel.exerciseRepository.getExerciseById(exerciseId) exerciseName = exercise?.name ?: unknownExerciseLabel + exerciseIsBodyweight = exercise?.isBodyweight == true // Clear topbar title to allow dynamic title from EnhancedMainScreen viewModel.updateTopBarTitle("") } val resolveCurrentOneRepMax: ResolveCurrentOneRepMaxUseCase = koinInject() val loadGate = remember { ExerciseDetailOneRepMaxLoadGate() } - val request = remember(exerciseId, profileId, exerciseSessions) { + val request = remember(exerciseId, profileId, exerciseSessions, exerciseIsBodyweight) { profileId?.let { ExerciseDetailOneRepMaxRequest( exerciseId = exerciseId, profileId = it, completedSessions = exerciseSessions, + isBodyweight = exerciseIsBodyweight, ) } } @@ -176,9 +180,9 @@ fun ExerciseDetailScreen( ) } - val validSessionEstimatesNewestFirst = remember(exerciseSessions) { + val validSessionEstimatesNewestFirst = remember(exerciseSessions, exerciseIsBodyweight) { exerciseSessions.mapNotNull { session -> - session.estimatedOneRepMaxPerCableOrNull() + session.estimatedOneRepMaxPerCableOrNull(exerciseIsBodyweight) ?.let { estimate -> session.timestamp to estimate } } } @@ -189,9 +193,9 @@ fun ExerciseDetailScreen( validSessionEstimatesNewestFirst.getOrNull(1)?.second // Weight-over-time trend data using saved per-cable load. - val weightTrendData = remember(exerciseSessions) { + val weightTrendData = remember(exerciseSessions, exerciseIsBodyweight) { exerciseSessions.mapNotNull { session -> - val load = session.displayHeaviestKgPerCable() + val load = session.displayHeaviestKgPerCable(exerciseIsBodyweight) if (load > 0) { session.timestamp to load } else { @@ -336,6 +340,7 @@ fun ExerciseDetailScreen( session = session, weightUnit = weightUnit, formatWeight = viewModel::formatWeight, + isBodyweight = exerciseIsBodyweight, ) } } @@ -346,6 +351,7 @@ fun ExerciseDetailScreen( sessions = exerciseSessions, weightUnit = weightUnit, formatWeight = viewModel::formatWeight, + isBodyweight = exerciseIsBodyweight, ) } } @@ -661,7 +667,12 @@ private fun VolumeChartCard(sessions: List, weightUnit: WeightUn } @Composable -private fun ExerciseHistoryTable(sessions: List, weightUnit: WeightUnit, formatWeight: (Float, WeightUnit) -> String) { +private fun ExerciseHistoryTable( + sessions: List, + weightUnit: WeightUnit, + formatWeight: (Float, WeightUnit) -> String, + isBodyweight: Boolean, +) { if (sessions.isEmpty()) { Text( "No workout history for this exercise.", @@ -739,7 +750,7 @@ private fun ExerciseHistoryTable(sessions: List, weightUnit: Wei ) TableCell( WeightDisplayFormatter.formatDisplayWeight( - session.displayHeaviestKgPerCable(), + session.displayHeaviestKgPerCable(isBodyweight), null, weightUnit, ), @@ -754,7 +765,7 @@ private fun ExerciseHistoryTable(sessions: List, weightUnit: Wei Modifier.weight(1f), ) TableCell( - session.estimatedOneRepMaxPerCableOrNull() + session.estimatedOneRepMaxPerCableOrNull(isBodyweight) ?.let { formatWeight(it, weightUnit) } ?: "-", Modifier.weight(1f), @@ -798,7 +809,12 @@ private fun TableCell(text: String, modifier: Modifier = Modifier) { } @Composable -private fun SessionHistoryRow(session: WorkoutSession, weightUnit: WeightUnit, formatWeight: (Float, WeightUnit) -> String) { +private fun SessionHistoryRow( + session: WorkoutSession, + weightUnit: WeightUnit, + formatWeight: (Float, WeightUnit) -> String, + isBodyweight: Boolean, +) { var isExpanded by remember { mutableStateOf(false) } ExpressiveCard( @@ -825,7 +841,7 @@ private fun SessionHistoryRow(session: WorkoutSession, weightUnit: WeightUnit, f color = MaterialTheme.colorScheme.onSurface, ) Text( - "${WeightDisplayFormatter.formatDisplayWeight(session.displayHeaviestKgPerCable(), null, weightUnit)} × ${session.workingReps} reps", + "${WeightDisplayFormatter.formatDisplayWeight(session.displayHeaviestKgPerCable(isBodyweight), null, weightUnit)} × ${session.workingReps} reps", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt index ba88bdc49..0f17fa294 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt @@ -228,9 +228,10 @@ class WorkoutSessionTest { heaviestLiftKg = Float.POSITIVE_INFINITY, ) - // WorkoutSession does not persist Exercise.isBodyweight, so rack counterweight alone - // cannot prove this zero was an intentional bodyweight effective load. - assertEquals(30f, counterweightedSession.displayHeaviestKgPerCable()) + // Counterweight alone cannot prove a bodyweight set, but a screen that has the + // canonical Exercise.isBodyweight classification can preserve a real zero. + assertEquals(30f, counterweightedSession.displayHeaviestKgPerCable(isBodyweight = false)) + assertEquals(0f, counterweightedSession.displayHeaviestKgPerCable(isBodyweight = true)) assertEquals(30f, nonFiniteMeasuredSession.displayHeaviestKgPerCable()) } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt index 0f441ace6..44757713b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt @@ -162,11 +162,15 @@ class ResolveCurrentOneRepMaxUseCaseTest { val zeroMeasuredResult = zeroMeasuredSession.estimatedOneRepMaxPerCableOrNull() assertEquals(56.25f, zeroMeasuredResult!!, 0.1f) // falls back to weightPerCableKg=50 - // WorkoutSession does not persist bodyweight classification, so counterweight alone - // cannot distinguish a legitimate zero bodyweight load from an invalid machine metric. - // Preserve the canonical configured-load fallback used by history SQL. + // Preserve a deliberate zero only when the caller supplies the canonical exercise + // classification; machine sessions with counterweight retain the SQL fallback. val counterweightedBodyweightSession = zeroMeasuredSession.copy(counterweightKg = 100f) - assertEquals(56.25f, counterweightedBodyweightSession.estimatedOneRepMaxPerCableOrNull()!!, 0.1f) + assertEquals( + 56.25f, + counterweightedBodyweightSession.estimatedOneRepMaxPerCableOrNull(isBodyweight = false)!!, + 0.1f, + ) + assertNull(counterweightedBodyweightSession.estimatedOneRepMaxPerCableOrNull(isBodyweight = true)) } @Test diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt index 7f3d0d093..f615da4de 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt @@ -10,6 +10,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -32,7 +33,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestA, gate = gate, - resolve = { _, _ -> + resolve = { _, _, _ -> aStarted.complete(Unit) releaseA.await() resultA @@ -46,7 +47,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestB, gate = gate, - resolve = { _, _ -> resultB }, + resolve = { _, _, _ -> resultB }, publish = states::add, ) releaseA.complete(Unit) @@ -72,7 +73,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestA, gate = gate, - resolve = { _, _ -> + resolve = { _, _, _ -> aStarted.complete(Unit) releaseA.await() error("late A failure") @@ -85,7 +86,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestB, gate = gate, - resolve = { _, _ -> resultB }, + resolve = { _, _, _ -> resultB }, publish = states::add, ) releaseA.complete(Unit) @@ -103,7 +104,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestA, gate = ExerciseDetailOneRepMaxLoadGate(), - resolve = { _, _ -> + resolve = { _, _, _ -> started.complete(Unit) awaitCancellation() }, @@ -131,7 +132,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestA, gate = ExerciseDetailOneRepMaxLoadGate(), - resolve = { _, _ -> error("resolver unavailable") }, + resolve = { _, _, _ -> error("resolver unavailable") }, publish = states::add, ) @@ -144,6 +145,23 @@ class ExerciseDetailOneRepMaxLoadTest { ) } + @Test + fun `bodyweight context is forwarded to the resolver`() = runTest { + var receivedBodyweight = false + + loadExerciseDetailOneRepMax( + request = requestA.copy(isBodyweight = true), + gate = ExerciseDetailOneRepMaxLoadGate(), + resolve = { _, _, isBodyweight -> + receivedBodyweight = isBodyweight + resultA + }, + publish = {}, + ) + + assertTrue(receivedBodyweight) + } + @Test fun `screen has one resolver path and no legacy profile or velocity read`() { val source = readProjectFile( @@ -152,7 +170,7 @@ class ExerciseDetailOneRepMaxLoadTest { assertNotNull(source) assertContains(source, "assessmentProfileId") assertContains(source, "loadExerciseDetailOneRepMax(") - assertContains(source, "estimatedOneRepMaxPerCableOrNull()") + assertContains(source, "estimatedOneRepMaxPerCableOrNull(exerciseIsBodyweight)") assertContains(source, "catch (cancellation: CancellationException)") assertContains(source, "catch (_: Exception)") assertFalse(source.contains("catch (_: Throwable)"))