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 041e18515..643fb0952 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 @@ -243,6 +243,19 @@ class ActiveSessionEngine( private var velocityThresholdAlertEmitted = false private var consecutiveThresholdReps = 0 + // Issue #649: defer position/stall auto-stop until the cue + short + // transition window elapses, or a completed working rep clears it. The + // deadline (@Volatile Long) is the single source of truth — 0L means no + // defer. checkVelocityThreshold() arms it on Dispatchers.Default; + // checkAutoStop() reads it on the metrics thread. @Volatile is required so + // the metrics thread never sees a stale old deadline across a thread switch. + // Resets: resetAutoStopState() (skip/restart/stop), per-set reset sites, + // and the next completed working rep all zero it. + // ponytail: one Long + one const ceiling; a tunable window is the only thing + // we'd add if users reported legitimate pauses being ended by this. + @kotlin.concurrent.Volatile + private var deferAutoStopDeadlineMs = 0L + // ===== Init Block: Workout-Related Collectors (moved from DWSM) ===== init { @@ -955,6 +968,9 @@ class ActiveSessionEngine( coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false coordinator._autoStopState.value = AutoStopUiState() + // Issue #649: zero the deadline so a new set never inherits a stale + // verbal-cue defer (skip / restart / startWorkout paths included). + deferAutoStopDeadlineMs = 0L } /** @@ -1080,6 +1096,11 @@ class ActiveSessionEngine( // Score the rep if rep count actually incremented val repCountAfter = repCounter.getRepCount().totalReps if (repCountAfter > repCountBefore) { + // Issue #649: a completed working rep proves the user is back in + // motion; let normal AMRAP / stall auto-stop resume by zeroing the + // deadline (the source-of-truth field). + deferAutoStopDeadlineMs = 0L + // Capture rep boundary timestamp BEFORE scoring so scoreCurrentRep() // and processBiomechanicsForRep() both see the correct metric window. val now = KmpUtils.currentTimeMillis() @@ -1327,6 +1348,29 @@ class ActiveSessionEngine( ), ) Logger.i { "VBT: VERBAL_ENCOURAGEMENT emitted (tier=${prefs.vulgarTier}, dominatrix=${prefs.dominatrixModeActive}, vulgar=${prefs.vulgarModeEnabled})" } + + // Issue #649: when the user has VBT auto-end OFF, the verbal cue is + // the only velocity signal — don't let AMRAP position / velocity-stall + // timers end the set while the cue is still audible. Defer both timers + // until the deadline (cue + short transition window) elapses + // or a completed working rep clears it. The deadline field + // alone is the source of truth — no derived flag to keep in + // sync. + // + // Issue #649 (Codex P2, race-window): checkVelocityThreshold runs + // on Dispatchers.Default; a stale callback resumed after a set + // transition could publish a fresh deadline into the next set. + // Same race characteristic as velocityThresholdAlertEmitted above. + // Narrow the window for this fix by re-checking the active state + // before writing; if the workout has already left Active (manual + // stop / reset / next set started), drop the arm silently. + if (!coordinator.autoEndOnVelocityLoss && + coordinator._workoutState.value is WorkoutState.Active + ) { + deferAutoStopDeadlineMs = currentTimeMillis() + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS + resetStallTimer() + resetAutoStopTimer() + } } } @@ -1404,6 +1448,23 @@ class ActiveSessionEngine( return } + // Issue #649: while a verbal VBT cue is still in flight and the user has VBT + // auto-end OFF, neither AMRAP position nor velocity-stall may end the set. + // The deadline field is the only source of truth — 0L means no defer. + // Any reset path (next completed working rep, resetAutoStopState, per-set + // boundary, deadline expiry) zeros it; this predicate then falls through. + // Reset the live countdowns defensively each metric. + val deferDeadline = deferAutoStopDeadlineMs + if (deferDeadline != 0L) { + if (currentTimeMillis() >= deferDeadline) { + deferAutoStopDeadlineMs = 0L + } else { + resetStallTimer() + resetAutoStopTimer() + return + } + } + val hasMeaningfulRange = repCounter.hasMeaningfulRange(WorkoutCoordinator.MIN_RANGE_THRESHOLD) val params = coordinator._workoutParameters.value val repCount = coordinator._repCount.value @@ -2007,6 +2068,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 + deferAutoStopDeadlineMs = 0L coordinator.repBoundaryTimestamps.value = emptyList() coordinator.warmupCompleteTimeMs = 0 // Reset variable warm-up state @@ -2429,6 +2491,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 + deferAutoStopDeadlineMs = 0L coordinator.repQualityScorer.reset() coordinator._latestRepQuality.value = null coordinator._loadBaselineA.value = 0f @@ -3968,6 +4031,7 @@ class ActiveSessionEngine( coordinator.biomechanicsEngine.reset() velocityThresholdAlertEmitted = false consecutiveThresholdReps = 0 + deferAutoStopDeadlineMs = 0L coordinator.repBoundaryTimestamps.value = emptyList() val completedReps = coordinator._repCount.value.workingReps @@ -5005,5 +5069,9 @@ class ActiveSessionEngine( private companion object { const val TEMPLATE_531_ID = "template_531" + // Issue #649: verbal cues are typically <30s; this ceiling covers the cue + // plus a short post-cue transition window. Exceeding it releases the defer + // so a racked mid-set handle can end the set normally. + const val VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS = 30_000L } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt new file mode 100644 index 000000000..0817dab2f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementAutoStopDeferTest.kt @@ -0,0 +1,172 @@ +package com.devil.phoenixproject.presentation.manager + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #649: while a verbal VBT cue is playing and the user has VBT auto-end OFF, + * neither the AMRAP position-based nor the velocity-stall auto-stop may end the set. + * + * Mirrors the velocity-threshold state machine from VbtAutoEndTest / VerbalEncouragementGateTest. + * Keeps the logic testable without spinning up the 17+ dependency ActiveSessionEngine. + * + * Single source of truth is a @Volatile Long deadline — 0L means no defer. The + * tracker mirrors that pattern: an `isDeferActive(nowMs)` predicate derived from + * the deadline only, with the flag computed lazily inside the predicate. + */ +private const val VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS_TRACKER = 30_000L + +private class VerbalEncouragementAutoStopDeferTracker( + private val autoEndOnVelocityLoss: Boolean, +) { + private var deferDeadlineMs: Long = 0L + + fun onRepCompleted(shouldStopSet: Boolean, nowMs: Long) { + if (shouldStopSet) { + if (deferDeadlineMs == 0L && !autoEndOnVelocityLoss) { + // Verbal cue fires once per set when VBT auto-end is OFF. + deferDeadlineMs = nowMs + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS_TRACKER + } + } + } + + fun onCompletedWorkingRep() { + // A completed working rep proves the user is back in motion; let normal + // AMRAP / stall auto-stop resume by zeroing the deadline. + deferDeadlineMs = 0L + } + + /** Returns true if the defer is still active at `nowMs`. */ + fun isDeferActive(nowMs: Long): Boolean { + val deadline = deferDeadlineMs + if (deadline == 0L) return false + if (nowMs >= deadline) { + deferDeadlineMs = 0L + return false + } + return true + } + + /** Mirrors resetAutoStopState(): every new-set reset clears the defer. */ + fun onResetAutoStopState() { + deferDeadlineMs = 0L + } + + /** Test seam: read the published deadline (mirrors the @Volatile field). */ + fun deferDeadlineSnapshot(): Long = deferDeadlineMs + + /** Test seam: true when no defer is armed (one-shot is fired unless VBT auto-end). */ + fun armEligible(): Boolean = deferDeadlineMs == 0L && !autoEndOnVelocityLoss +} + +class VerbalEncouragementAutoStopDeferTest { + + @Test + fun `autoEnd off - first velocity-threshold rep arms the defer with a future deadline`() { + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) + + assertEquals(30_000L, tracker.deferDeadlineSnapshot()) + assertTrue(tracker.isDeferActive(nowMs = 0L)) + assertTrue(tracker.isDeferActive(nowMs = 29_999L)) + } + + @Test + fun `autoEnd on - first velocity-threshold rep does NOT arm the defer`() { + // With VBT auto-end ON, the existing two-consecutive-rep behavior decides set end; + // the defer guard would only suppress that and must NOT be armed. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = true) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) + + assertEquals(0L, tracker.deferDeadlineSnapshot()) + assertFalse(tracker.isDeferActive(nowMs = 0L)) + } + + @Test + fun `next completed working rep clears the defer regardless of deadline remaining`() { + // Verbal cue armed at t=5s, user completes a working rep at t=10s. + // The defer must clear immediately, even though the 30s window hasn't expired, + // so AMRAP / stall auto-stop can fire normally on subsequent metrics. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 5_000L) + assertTrue(tracker.isDeferActive(nowMs = 10_000L)) + + tracker.onCompletedWorkingRep() + + assertEquals(0L, tracker.deferDeadlineSnapshot()) + assertFalse( + tracker.isDeferActive(nowMs = 10_000L), + "After a completed working rep the defer must clear so AMRAP/stall can fire again", + ) + assertFalse( + tracker.isDeferActive(nowMs = 29_999L), + "Re-arm must require another velocity-threshold-triggered cue, not automatic re-derivation", + ) + } + + @Test + fun `autoEnd off - non-threshold reps do not arm the defer`() { + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = false, nowMs = 0L) + tracker.onRepCompleted(shouldStopSet = false, nowMs = 0L) + + assertEquals(0L, tracker.deferDeadlineSnapshot()) + assertFalse(tracker.isDeferActive(nowMs = 0L)) + assertTrue(tracker.armEligible(), "Cue hasn't fired yet, so arm remains eligible") + } + + @Test + fun `defer stays active within the cue window`() { + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 10_000L) + + assertTrue(tracker.isDeferActive(nowMs = 20_000L)) + } + + @Test + fun `defer expires at the cue window deadline`() { + // User rackets handles mid-set after the cue and never completes a working rep. + // Once the deadline passes, the defer must release so AMRAP/stall auto-stop can fire. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 0L) + + assertTrue(tracker.isDeferActive(nowMs = 29_999L), "Just before deadline still defers") + assertFalse( + tracker.isDeferActive(nowMs = 30_001L), + "Past the deadline the defer must clear so auto-stop can fire normally", + ) + assertEquals(0L, tracker.deferDeadlineSnapshot(), "Expiry must zero the deadline") + } + + @Test + fun `resetAutoStopState clears the defer for the next set`() { + // Verbal cue armed the defer mid-set. Before the 30s deadline the user + // skips / restarts. The next active set must not inherit the defer — + // otherwise checkAutoStop() would suppress AMRAP/stall auto-stop there too. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 10_000L) + assertTrue(tracker.isDeferActive(nowMs = 20_000L)) + + tracker.onResetAutoStopState() + + assertEquals(0L, tracker.deferDeadlineSnapshot()) + assertTrue(tracker.armEligible(), "Next set must allow a fresh cue to arm again") + assertFalse( + tracker.isDeferActive(nowMs = 20_000L), + "After resetAutoStopState() the new set must allow normal auto-stop behavior", + ) + } + + @Test + fun `arm publishes future deadline that a metrics-thread reader sees immediately`() { + // Single-source-of-truth guarantee: the deadline field is the gate. + // Even a reader that observes the field the instant the cue fires sees + // a future deadline, never the stale 0L value. + val tracker = VerbalEncouragementAutoStopDeferTracker(autoEndOnVelocityLoss = false) + tracker.onRepCompleted(shouldStopSet = true, nowMs = 5_000L) + assertEquals(35_000L, tracker.deferDeadlineSnapshot()) + assertTrue(tracker.isDeferActive(nowMs = 5_000L)) + } +}