From b88dc44e863dd7b8a3d70b078c6d4885b171ea62 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 16 Aug 2026 00:21:26 -0400 Subject: [PATCH 1/6] fix: exempt Just Lift leases from target mismatch and terminal checks in RepNotificationFreshnessGate Issue #698: Echo Just Lift commands use unlimited target semantics (0xFF/252), but the modern rep freshness gate required the device-reported repsSetTotal to equal the finite UI lease workingRepTarget. This caused every valid Just Lift packet to be dropped as TARGET_MISMATCH before rep counting, warmup, audio feedback, or auto-stop could fire. Fix: gate the target-equality and finite-terminal checks on !lease.isJustLift using the existing isJustLift field on ExecutionLease. Acceptance criteria: - Just Lift modern packets with repsSetTotal=252 pass the freshness gate - Just Lift repsSetCount is not treated as terminal - Finite-target executions still reject nonzero mismatched targets - Pre-cutover, invalidated, and non-current packets remain rejected Fixes #698 --- .../manager/RepNotificationFreshnessGate.kt | 11 ++++- .../RepNotificationFreshnessGateTest.kt | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) 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..7c7d36170 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 @@ -56,12 +56,19 @@ internal class RepNotificationFreshnessGate { val identity = lease.identity() if (notification.isLegacyFormat) return evaluateLegacy(identity, notification) - val targetMatches = notification.repsSetTotal == 0 || + // Issue #698: Just Lift uses unlimited target semantics (0xFF/252), + // so the device-reported repsSetTotal will never match the finite UI + // lease target. Exempt Just Lift leases from target equality check. + val targetMatches = lease.isJustLift || + 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 && 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..ebacab8f8 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,49 @@ 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 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 `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", From b7fee41271860e1cd640ac9dca0baf082b7a46a9 Mon Sep 17 00:00:00 2001 From: Phoenix Worker Date: Sun, 16 Aug 2026 00:30:35 -0400 Subject: [PATCH 2/6] fix: restrict Just Lift exemption to unlimited repsSetTotal (Codex P1) Address Codex review: accept only the known unlimited representation (252) or zero for Just Lift leases, rejecting stale packets from prior finite-target sets that could corrupt reps and feedback. - Add UNLIMITED_REPS_SET_TOTAL constant (252) - Gate targetMatches on specific unlimited value, not blanket isJustLift - Add test: just lift rejects stale finite repsSetTotal from prior execution --- .../manager/RepNotificationFreshnessGate.kt | 12 ++++++++++-- .../manager/RepNotificationFreshnessGateTest.kt | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) 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 7c7d36170..0c9e5eb42 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 @@ -58,8 +58,11 @@ internal class RepNotificationFreshnessGate { // Issue #698: Just Lift uses unlimited target semantics (0xFF/252), // so the device-reported repsSetTotal will never match the finite UI - // lease target. Exempt Just Lift leases from target equality check. - val targetMatches = lease.isJustLift || + // lease target. Accept only the known unlimited representation or zero + // for Just Lift; reject stale packets from prior finite-target sets. + val targetMatches = (lease.isJustLift && + (notification.repsSetTotal == UNLIMITED_REPS_SET_TOTAL || + notification.repsSetTotal == 0)) || notification.repsSetTotal == 0 || notification.repsSetTotal == lease.workingRepTarget if (!targetMatches) return RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH) @@ -115,4 +118,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/RepNotificationFreshnessGateTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt index ebacab8f8..5e9798eac 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 @@ -186,6 +186,21 @@ class RepNotificationFreshnessGateTest { ) } + @Test + fun `just lift rejects stale finite repsSetTotal from prior execution`() { + 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))) + + // Stale packet from prior finite-target set (repsSetTotal=15) must be rejected + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 15, timestamp = 1_002L)), + ) + } + @Test fun `finite lease still rejects mismatched repsSetTotal after fix`() { val gate = RepNotificationFreshnessGate() From e834407ac70885cfb5497b288a78b835e6ddbd52 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 16 Aug 2026 00:50:19 -0400 Subject: [PATCH 3/6] fix: reject matching finite targets for Just Lift Address Codex P1 on #699: a delayed finite-set packet whose repsSetTotal happened to equal the Just Lift UI target still passed the generic equality fallback. Restrict Just Lift to only the documented unlimited target (252) or zero, and add a matching-target stale-packet regression test. --- .../manager/RepNotificationFreshnessGate.kt | 13 ++++++++----- .../manager/RepNotificationFreshnessGateTest.kt | 7 ++++--- 2 files changed, 12 insertions(+), 8 deletions(-) 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 0c9e5eb42..fd2ddd3cf 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 @@ -59,12 +59,15 @@ internal class RepNotificationFreshnessGate { // 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. - val targetMatches = (lease.isJustLift && - (notification.repsSetTotal == UNLIMITED_REPS_SET_TOTAL || - notification.repsSetTotal == 0)) || + // 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 + notification.repsSetTotal == lease.workingRepTarget + } if (!targetMatches) return RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH) if (stateFor(lease) is RepFreshnessState.Armed) return RepFreshnessDecision.Process 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 5e9798eac..e283d5a38 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 @@ -187,17 +187,18 @@ class RepNotificationFreshnessGateTest { } @Test - fun `just lift rejects stale finite repsSetTotal from prior execution`() { + 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))) - // Stale packet from prior finite-target set (repsSetTotal=15) must be rejected + // 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 = 15, timestamp = 1_002L)), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 10, timestamp = 1_002L)), ) } From e3bb61556533d4921dfc9bb9a4cb4e52afd18983 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 16 Aug 2026 01:04:27 -0400 Subject: [PATCH 4/6] fix: require fresh evidence for Just Lift progress Address Codex P1 on #699 by holding nonzero unlimited Just Lift notifications until a zero baseline or observed movement arms the new execution. Update the Issue 267 lifecycle fixture to emit the documented unlimited target (252) and baseline so CI models the device protocol. --- .../manager/RepNotificationFreshnessGate.kt | 4 ++ .../manager/DWSMWorkoutLifecycleTest.kt | 46 ++++++++++++++++--- .../RepNotificationFreshnessGateTest.kt | 37 +++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) 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 fd2ddd3cf..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, } @@ -91,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 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..ad4663fe6 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 @@ -1717,14 +1717,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 +2242,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 +2258,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 +2284,7 @@ class DWSMWorkoutLifecycleTest { repsRomCount = warmupRep, repsRomTotal = warmupTarget, repsSetCount = 0, - repsSetTotal = workingTarget, + repsSetTotal = repsSetTotal, rangeTop = 800f, rangeBottom = 0f, rawData = ByteArray(24), @@ -2746,7 +2775,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 +2798,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 e283d5a38..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 @@ -172,6 +172,43 @@ class RepNotificationFreshnessGateTest { ) } + @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() From 80b94a9c917954c0943d294f6f45c86eb7540a3f Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 16 Aug 2026 01:20:36 -0400 Subject: [PATCH 5/6] fix: arm Just Lift freshness on confirmed grab --- .../manager/ActiveSessionEngine.kt | 8 +++- .../manager/DWSMWorkoutLifecycleTest.kt | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) 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..9ad39881f 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) ) { 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 ad4663fe6..fa49349db 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,47 @@ 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 `Issue 267 Just Lift warmup to working rep transitions without failed stall state`() = runTest { val harness = DWSMTestHarness(this) From 1fafc9d300ef1ec3d56cba9346016d79310f89c0 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 16 Aug 2026 01:31:33 -0400 Subject: [PATCH 6/6] fix: carry Just Lift grab evidence into new lease --- .../manager/ActiveSessionEngine.kt | 6 +++ .../manager/DWSMWorkoutLifecycleTest.kt | 37 +++++++++++++++++++ 2 files changed, 43 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 9ad39881f..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 @@ -3284,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/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt index fa49349db..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 @@ -1737,6 +1737,43 @@ class DWSMWorkoutLifecycleTest { 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)