From 0a6a4d13a8c91a472e778f6f8ba240e6dfe64aae Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 13 Jul 2026 20:49:20 -0400 Subject: [PATCH] fix(#652): reset shared stall timer on completed working rep The shared stall countdown (WorkoutCoordinator.stallStartTime) is armed by both low-velocity motion and firmware DELOAD_OCCURRED, but until now was cancelled only by a metric above 10 mm/s. PR #650 cleared the verbal-cue defer deadline at the completed-rep boundary on the same premise: a completed working rep is authoritative proof the athlete is back in motion. Without resetting the stall timer at that boundary, a countdown started at a turnaround, brief pause, or firmware de-load could survive a subsequent valid rep and later triggerAutoStop() -> handleSetCompletion(), prematurely ending a set the athlete is still performing. Add resetStallTimer() alongside deferAutoStopDeadlineMs = 0L inside the repCountAfter > repCountBefore branch of handleRepNotification. This is the narrow shared fix for both stall arms and preserves issue #256: a pending first working rep has not completed, so its timer remains armed and the existing 5-second stall guard still fires. TDD evidence: - RED characterization (Issue 652 test) reproduced on main@0f00f4b: stallStartTime remained set after a completed second working rep. - GREEN after the fix; full :shared:testAndroidHostTest suite (2442 tests) and issue #256 tests stay green. No thresholds, setting semantics, AMRAP position auto-stop, VBT Auto-End, or unrelated code changed. Stall Detection remains independent from autoEndOnVelocityLoss. Fixes #652 --- .../manager/ActiveSessionEngine.kt | 6 +++ .../manager/DWSMWorkoutLifecycleTest.kt | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 643fb0952..af0a56e0a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -1100,6 +1100,12 @@ class ActiveSessionEngine( // motion; let normal AMRAP / stall auto-stop resume by zeroing the // deadline (the source-of-truth field). deferAutoStopDeadlineMs = 0L + // Issue #652: the same completed-rep boundary is authoritative for + // the shared stall countdown (both DELOAD_OCCURRED and low-velocity + // arms write to coordinator.stallStartTime). Without this, a stale + // countdown started at a turnaround, brief pause, or firmware de-load + // can survive a subsequent valid rep and later auto-complete the set. + resetStallTimer() // Capture rep boundary timestamp BEFORE scoring so scoreCurrentRep() // and processBiomechanicsForRep() both see the correct metric window. diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt index 77ce980a7..6316d1adb 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt @@ -1204,6 +1204,58 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } + @Test + fun `Issue 652 - completed Echo rep cancels an armed stall timer`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.Echo, + echoLevel = EchoLevel.HARDER, + reps = 10, + warmupReps = 0, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 10) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 10) + advanceUntilIdle() + + harness.fakeBleRepo.emitDeloadOccurred() + advanceUntilIdle() + assertNotNull(harness.dwsm.coordinator.stallStartTime) + + harness.fakeBleRepo.emitRepNotification( + RepNotification( + topCounter = 5, + completeCounter = 5, + repsRomCount = 3, + repsRomTotal = 3, + repsSetCount = 2, + repsSetTotal = 10, + rangeTop = 800f, + rangeBottom = 0f, + rawData = ByteArray(24), + timestamp = 5L, + ), + ) + advanceUntilIdle() + + assertEquals(2, harness.dwsm.coordinator.repCount.value.workingReps) + assertEquals( + null, + harness.dwsm.coordinator.stallStartTime, + "A completed working rep proves motion resumed and must cancel the stale stall countdown", + ) + assertFalse(harness.dwsm.coordinator.isCurrentlyStalled) + assertIs(harness.dwsm.coordinator.workoutState.value) + harness.cleanup() + } + @Test fun `Issue 267 Just Lift warmup to working rep transitions without failed stall state`() = runTest { val harness = DWSMTestHarness(this)