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..ef9e9dd3f 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,22 @@ internal class RepNotificationFreshnessGate { val identity = lease.identity() if (notification.isLegacyFormat) return evaluateLegacy(identity, notification) - val targetMatches = notification.repsSetTotal == 0 || + // Issue #698/#700: Just Lift and AMRAP with target=0 use unlimited + // target semantics (0xFF/252), so the device-reported repsSetTotal + // will never match the finite UI lease target. Exempt both from + // target equality check. AMRAP with a finite target (>0) must still + // match — only unlimited AMRAP gets the exemption. + val targetMatches = lease.isJustLift || + (lease.isAmrap && lease.workingRepTarget == 0) || + 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/#700: Just Lift and AMRAP have no finite rep target, + // so repsSetCount should never be treated as terminal. + val terminal = !(lease.isJustLift || lease.isAmrap) && + 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..28f4910a9 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,103 @@ 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)), + ) + } + + // --- Issue #700: AMRAP target mismatch exemption (mirrors #698 Just Lift tests) --- + + @Test + fun `amrap lease accepts repsSetTotal 252 despite finite UI target`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 0, cutover = 1_000L).copy(isAmrap = 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 for AMRAP + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_002L)), + ) + } + + @Test + fun `amrap lease does not treat repsSetCount as terminal`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 0, cutover = 1_000L).copy(isAmrap = true) + + // AMRAP should never treat repsSetCount as terminal, same as Just Lift + assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 5, repsSetTotal = 252, timestamp = 1_002L)), + ) + } + + @Test + fun `finite amrap lease still rejects mismatched repsSetTotal`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 3, cutover = 1_000L).copy(isAmrap = true) + + // Even with isAmrap=true, a mismatched finite repsSetTotal should be rejected + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 4, timestamp = 1_001L)), + ) + } + + @Test + fun `pre-cutover amrap packet is still rejected`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 0, cutover = 1_000L).copy(isAmrap = true) + + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.PRE_CUTOVER_TIMESTAMP), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 999L)), + ) + } + private fun activeLease(target: Int, cutover: Long) = ExecutionLease( executionId = 1L, sessionId = "session-a",