Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard late VBT callbacks before arming a defer

When biomechanics work from a completed set runs late, this assignment can publish a fresh 30s defer after startWorkout() has already reset state for the next set. processBiomechanicsForRep() launches on the shared scope and checkVelocityThreshold() does not verify that the analyzed rep still belongs to the current set, so in a quick set transition/autostart path an old verbal-cue callback can make the new active set's checkAutoStop() suppress AMRAP/stall auto-stop until the timeout or another rep clears it. Capture and validate a set/session generation before arming the deadline; a simple Active-state check is not enough once the next set has started.

Useful? React with 👍 / 👎.

resetStallTimer()
resetAutoStopTimer()
}
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading