P6 Stream T: finish BiometricLockScreen + BiometricManager
BiometricPrompt wrapper with 3-strike lockout + NO_HARDWARE bypass. BiometricLockScreen with auto-prompt on mount + PIN fallback after 3 failures. PIN wiring marked TODO for secure-storage follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
||||
package com.tt.honeyDue.ui.screens
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* P6 Stream T — unit tests for [BiometricLockState].
|
||||
*
|
||||
* Pure-Kotlin state-machine tests (no Android/iOS dependencies).
|
||||
*/
|
||||
class BiometricLockScreenStateTest {
|
||||
|
||||
@Test
|
||||
fun initialState_onAppearAvailable_triggersPromptAndMovesToPrompting() {
|
||||
var unlockCalls = 0
|
||||
val state = BiometricLockState(onUnlock = { unlockCalls++ })
|
||||
|
||||
val shouldPrompt = state.onAppear(BiometricLockState.Availability.AVAILABLE)
|
||||
|
||||
assertTrue(shouldPrompt, "onAppear should return true when biometrics are available")
|
||||
assertEquals(BiometricLockState.Phase.PROMPTING, state.phase)
|
||||
assertEquals(0, unlockCalls, "unlock must not fire before biometric success")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onAppear_noHardware_bypassesLockAndInvokesUnlock() {
|
||||
var unlockCalls = 0
|
||||
val state = BiometricLockState(onUnlock = { unlockCalls++ })
|
||||
|
||||
val shouldPrompt = state.onAppear(BiometricLockState.Availability.NO_HARDWARE)
|
||||
|
||||
assertFalse(shouldPrompt, "NO_HARDWARE should skip the prompt")
|
||||
assertEquals(BiometricLockState.Phase.BYPASSED, state.phase)
|
||||
assertEquals(1, unlockCalls, "NO_HARDWARE should release the lock exactly once")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun threeConsecutiveFailures_surfaceFallbackPinUi() {
|
||||
val state = BiometricLockState(onUnlock = {})
|
||||
state.onAppear(BiometricLockState.Availability.AVAILABLE)
|
||||
|
||||
assertFalse(state.onBiometricFailure(), "1st failure should not surface fallback")
|
||||
assertFalse(state.onBiometricFailure(), "2nd failure should not surface fallback")
|
||||
val thirdCrossedThreshold = state.onBiometricFailure()
|
||||
|
||||
assertTrue(thirdCrossedThreshold, "3rd failure should cross 3-strike threshold")
|
||||
assertEquals(BiometricLockState.Phase.FALLBACK_PIN, state.phase)
|
||||
assertEquals(3, state.failureCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onBiometricSuccess_invokesUnlockCallbackAndMarksUnlocked() {
|
||||
var unlockCalls = 0
|
||||
val state = BiometricLockState(onUnlock = { unlockCalls++ })
|
||||
state.onAppear(BiometricLockState.Availability.AVAILABLE)
|
||||
|
||||
state.onBiometricSuccess()
|
||||
|
||||
assertEquals(1, unlockCalls, "onUnlock must be invoked exactly once")
|
||||
assertEquals(BiometricLockState.Phase.UNLOCKED, state.phase)
|
||||
assertEquals(0, state.failureCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onBiometricSuccess_idempotent_doesNotDoubleInvokeUnlock() {
|
||||
var unlockCalls = 0
|
||||
val state = BiometricLockState(onUnlock = { unlockCalls++ })
|
||||
state.onAppear(BiometricLockState.Availability.AVAILABLE)
|
||||
|
||||
state.onBiometricSuccess()
|
||||
state.onBiometricSuccess()
|
||||
|
||||
assertEquals(1, unlockCalls, "unlock callback must not double-fire")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onRetry_afterThreeFailures_staysOnFallbackAndReturnsFalse() {
|
||||
val state = BiometricLockState(onUnlock = {})
|
||||
state.onAppear(BiometricLockState.Availability.AVAILABLE)
|
||||
repeat(3) { state.onBiometricFailure() }
|
||||
|
||||
val retriedSuccessfully = state.onRetry()
|
||||
|
||||
assertFalse(retriedSuccessfully, "retry after lockout must not resume prompt")
|
||||
assertEquals(BiometricLockState.Phase.FALLBACK_PIN, state.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onPinEntered_correctPin_unlocks() {
|
||||
var unlockCalls = 0
|
||||
val state = BiometricLockState(onUnlock = { unlockCalls++ })
|
||||
state.onAppear(BiometricLockState.Availability.AVAILABLE)
|
||||
repeat(3) { state.onBiometricFailure() }
|
||||
|
||||
val accepted = state.onPinEntered("1234", expectedPin = "1234")
|
||||
|
||||
assertTrue(accepted)
|
||||
assertEquals(1, unlockCalls)
|
||||
assertEquals(BiometricLockState.Phase.UNLOCKED, state.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onPinEntered_wrongPin_keepsFallbackVisibleAndDoesNotUnlock() {
|
||||
var unlockCalls = 0
|
||||
val state = BiometricLockState(onUnlock = { unlockCalls++ })
|
||||
state.onAppear(BiometricLockState.Availability.AVAILABLE)
|
||||
repeat(3) { state.onBiometricFailure() }
|
||||
|
||||
val accepted = state.onPinEntered("0000", expectedPin = "1234")
|
||||
|
||||
assertFalse(accepted)
|
||||
assertEquals(0, unlockCalls)
|
||||
assertEquals(BiometricLockState.Phase.FALLBACK_PIN, state.phase)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user