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 9f0e35058..d0a70bf01 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 @@ -546,7 +546,13 @@ class ActiveSessionEngine( val params = coordinator._workoutParameters.value val currentState = coordinator._workoutState.value val currentLease = executionGuard.currentLease - if (activityState == HandleState.Moving && + // A confirmed Just Lift grab proves a fresh execution is in motion. + // HandleStateDetector can transition directly Released -> Grabbed when the + // first pull already exceeds the velocity threshold, so waiting only for + // Moving would leave the freshness gate unarmed without a zero packet. + val hasFreshJustLiftGrab = activityState == HandleState.Grabbed && + currentLease?.isJustLift == true + if ((activityState == HandleState.Moving || hasFreshJustLiftGrab) && currentLease?.activationCutoverTimestampMs != null && executionGuard.isCurrent(currentLease) ) { @@ -3278,6 +3284,12 @@ class ActiveSessionEngine( val activeLease = executionGuard.activate(lease, wallClockMillisProvider()) ?: return@launch repFreshnessGate.resetFor(activeLease) + // Auto-start can confirm Grabbed before this lease becomes active. The polling + // restart preserves that confirmed detector state, so carry it into the new + // Just Lift lease rather than requiring a second state emission or zero packet. + if (activeLease.isJustLift && bleRepository.handleState.value == HandleState.Grabbed) { + repFreshnessGate.observeMovement(activeLease) + } bleRepository.startActiveWorkoutPolling() if (!executionGuard.isCurrent(activeLease)) return@launch diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt index db1d18015..f13f5f174 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt @@ -12,6 +12,7 @@ internal enum class RepDropReason { LEASE_NOT_ACTIVE, PRE_CUTOVER_TIMESTAMP, TARGET_MISMATCH, + PROGRESS_BEFORE_EVIDENCE, TERMINAL_BEFORE_EVIDENCE, } @@ -56,12 +57,25 @@ internal class RepNotificationFreshnessGate { val identity = lease.identity() if (notification.isLegacyFormat) return evaluateLegacy(identity, notification) - val targetMatches = notification.repsSetTotal == 0 || - notification.repsSetTotal == lease.workingRepTarget + // Issue #698: Just Lift uses unlimited target semantics (0xFF/252), + // so the device-reported repsSetTotal will never match the finite UI + // lease target. Accept only the known unlimited representation or zero + // for Just Lift; reject stale packets from prior finite-target sets, + // including one whose target happens to equal the UI lease target. + val targetMatches = if (lease.isJustLift) { + notification.repsSetTotal == UNLIMITED_REPS_SET_TOTAL || + notification.repsSetTotal == 0 + } else { + notification.repsSetTotal == 0 || + notification.repsSetTotal == lease.workingRepTarget + } if (!targetMatches) return RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH) if (stateFor(lease) is RepFreshnessState.Armed) return RepFreshnessDecision.Process - val terminal = lease.workingRepTarget > 0 && + // Issue #698: Just Lift has no finite rep target, so repsSetCount + // should never be treated as terminal. Exempt from terminal check. + val terminal = !lease.isJustLift && + lease.workingRepTarget > 0 && notification.repsSetCount >= lease.workingRepTarget val allZero = notification.topCounter == 0 && notification.completeCounter == 0 && @@ -78,6 +92,9 @@ internal class RepNotificationFreshnessGate { states[identity] = RepFreshnessState.Armed return RepFreshnessDecision.BaselineOnly } + if (lease.isJustLift && hasNonTerminalProgress) { + return RepFreshnessDecision.Drop(RepDropReason.PROGRESS_BEFORE_EVIDENCE) + } if (hasNonTerminalProgress) { states[identity] = RepFreshnessState.Armed return RepFreshnessDecision.Process @@ -108,4 +125,9 @@ internal class RepNotificationFreshnessGate { val executionId: Long, val sessionId: String, ) + + companion object { + /** repsSetTotal value the device sends for unlimited/Just Lift/AMRAP sets. */ + const val UNLIMITED_REPS_SET_TOTAL = 252 + } } 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 29891ea42..66cb09bca 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 @@ -1696,6 +1696,84 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } + @Test + fun `Just Lift warmup accepts unlimited progress after a confirmed handle grab without zero baseline`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 20f, + progressionRegressionKg = 0f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = true, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + assertIs(harness.dwsm.coordinator.workoutState.value) + + // The detector may transition directly from Released to Grabbed when the + // user pulls above the velocity threshold, with no Moving state emitted. + harness.fakeBleRepo.setHandleState(HandleState.Released) + advanceUntilIdle() + harness.fakeBleRepo.setHandleState(HandleState.Grabbed) + advanceUntilIdle() + + completeWarmupReps( + harness, + warmupTarget = 3, + workingTarget = 8, + repsSetTotal = 252, + ) + advanceUntilIdle() + + val afterWarmup = harness.dwsm.coordinator.repCount.value + assertTrue(afterWarmup.isWarmupComplete) + assertEquals(0, afterWarmup.workingReps) + harness.cleanup() + } + + @Test + fun `Just Lift warmup accepts unlimited progress when the confirmed grab predates lease activation`() = runTest { + val harness = DWSMTestHarness(this) + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.fakeBleRepo.setHandleState(HandleState.Grabbed) + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 20f, + progressionRegressionKg = 0f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = true, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + assertIs(harness.dwsm.coordinator.workoutState.value) + + // Auto-start can confirm Grabbed before the new execution lease is activated. + // startActiveWorkoutPolling preserves that state, so no second emission is required. + completeWarmupReps( + harness, + warmupTarget = 3, + workingTarget = 8, + repsSetTotal = 252, + ) + advanceUntilIdle() + + val afterWarmup = harness.dwsm.coordinator.repCount.value + assertTrue(afterWarmup.isWarmupComplete) + assertEquals(0, afterWarmup.workingReps) + harness.cleanup() + } + @Test fun `Issue 267 Just Lift warmup to working rep transitions without failed stall state`() = runTest { val harness = DWSMTestHarness(this) @@ -1717,14 +1795,20 @@ class DWSMWorkoutLifecycleTest { advanceUntilIdle() assertIs(harness.dwsm.coordinator.workoutState.value) - completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8) + completeWarmupReps( + harness, + warmupTarget = 3, + workingTarget = 8, + repsSetTotal = 252, + includeZeroBaseline = true, + ) advanceUntilIdle() val afterWarmup = harness.dwsm.coordinator.repCount.value assertTrue(afterWarmup.isWarmupComplete) assertEquals(0, afterWarmup.workingReps) - completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8, repsSetTotal = 252) advanceUntilIdle() val afterWorkingRep = harness.dwsm.coordinator.repCount.value @@ -2236,7 +2320,13 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } - private suspend fun completeWarmupReps(harness: DWSMTestHarness, warmupTarget: Int = 3, workingTarget: Int = 8) { + private suspend fun completeWarmupReps( + harness: DWSMTestHarness, + warmupTarget: Int = 3, + workingTarget: Int = 8, + repsSetTotal: Int = workingTarget, + includeZeroBaseline: Boolean = false, + ) { val activeMetric = WorkoutMetric( positionA = 120f, positionB = 120f, @@ -2246,6 +2336,23 @@ class DWSMWorkoutLifecycleTest { loadB = 10f, ) + if (includeZeroBaseline) { + harness.fakeBleRepo.emitRepNotification( + RepNotification( + topCounter = 0, + completeCounter = 0, + repsRomCount = 0, + repsRomTotal = warmupTarget, + repsSetCount = 0, + repsSetTotal = repsSetTotal, + rangeTop = 800f, + rangeBottom = 0f, + rawData = ByteArray(24), + timestamp = harness.nowMs, + ), + ) + } + for (warmupRep in 1..warmupTarget) { harness.fakeBleRepo.emitMetric(activeMetric) harness.fakeBleRepo.emitRepNotification( @@ -2255,7 +2362,7 @@ class DWSMWorkoutLifecycleTest { repsRomCount = warmupRep, repsRomTotal = warmupTarget, repsSetCount = 0, - repsSetTotal = workingTarget, + repsSetTotal = repsSetTotal, rangeTop = 800f, rangeBottom = 0f, rawData = ByteArray(24), @@ -2746,7 +2853,12 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } - private suspend fun completeFirstWorkingRep(harness: DWSMTestHarness, warmupTarget: Int = 3, workingTarget: Int = 8) { + private suspend fun completeFirstWorkingRep( + harness: DWSMTestHarness, + warmupTarget: Int = 3, + workingTarget: Int = 8, + repsSetTotal: Int = workingTarget, + ) { val activeMetric = WorkoutMetric( positionA = 120f, positionB = 120f, @@ -2764,7 +2876,7 @@ class DWSMWorkoutLifecycleTest { repsRomCount = warmupTarget, repsRomTotal = warmupTarget, repsSetCount = 1, - repsSetTotal = workingTarget, + repsSetTotal = repsSetTotal, rangeTop = 800f, rangeBottom = 0f, rawData = ByteArray(24), diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt index 17acf3c5a..98d933d38 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt @@ -154,6 +154,102 @@ class RepNotificationFreshnessGateTest { ) } + // --- Issue #698: Just Lift target mismatch exemption --- + + @Test + fun `just lift lease accepts repsSetTotal 252 despite finite UI target`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true) + + // First packet establishes baseline and arms + assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) + assertEquals(RepFreshnessState.Armed, gate.stateFor(lease)) + + // repsSetTotal=252 (unlimited) should NOT be dropped as TARGET_MISMATCH + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_002L)), + ) + } + + @Test + fun `just lift unlimited progress waits for a zero baseline or observed movement`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true) + + // A delayed packet from a prior Just Lift execution has the same + // unlimited target, so it cannot establish freshness by itself. + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.PROGRESS_BEFORE_EVIDENCE), + gate.evaluate(lease, modernPacket(repsSetCount = 5, repsSetTotal = 252, timestamp = 1_001L)), + ) + assertEquals(RepFreshnessState.AwaitingEvidence, gate.stateFor(lease)) + + // An all-zero packet establishes the new-session baseline. + assertEquals( + RepFreshnessDecision.BaselineOnly, + gate.evaluate(lease, modernPacket(repsSetTotal = 252, timestamp = 1_002L)), + ) + assertEquals(RepFreshnessState.Armed, gate.stateFor(lease)) + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_003L)), + ) + } + + @Test + fun `observed movement arms just lift unlimited progress`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true) + + assertTrue(gate.observeMovement(lease)) + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_001L)), + ) + } + + @Test + fun `just lift lease does not treat repsSetCount as terminal`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 3, cutover = 1_000L).copy(isJustLift = true) + + // repsSetCount=3 >= workingRepTarget=3 would be terminal for finite, + // but Just Lift should process it normally after baseline + assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 3, repsSetTotal = 252, timestamp = 1_002L)), + ) + } + + @Test + fun `just lift rejects stale finite repsSetTotal matching the UI target`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true) + + // Baseline arms + assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) + + // A stale packet from a prior finite-target set can happen to have the + // same target as the Just Lift UI lease. It must still be rejected. + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 10, timestamp = 1_002L)), + ) + } + + @Test + fun `finite lease still rejects mismatched repsSetTotal after fix`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 3, cutover = 1_000L) // isJustLift = false + + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_001L)), + ) + } + private fun activeLease(target: Int, cutover: Long) = ExecutionLease( executionId = 1L, sessionId = "session-a",