package com.peachpasswords.ui.lock import com.peachpasswords.ui.PeachNav import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test /** * Pins the unlock-routing decision in [routeOnUnlock]. The LaunchedEffect body in * [LockScreen] is a thin dispatcher over this function, so the priority order * (created > autofill > postUnlock) and the lazy `postUnlock` semantics are the * only thing worth locking down here. * * Background: a previous v2.14.5 guard put the autofill-pending branch inside * LockScreen and was keyed on both `state.unlocked` and `autofillPending`. The * NavHost's autofill LaunchedEffect consumed `newEntryRequest` synchronously, * which flipped `autofillPending` to false mid-unlock and re-fired LockScreen's * effect — landing the user on the vault list instead of the new-entry editor. * The fix keyed LockScreen's effect on `state.unlocked` only; these tests pin * the decision so a future reorder can't silently re-introduce the regression. */ class LockScreenRoutingTest { private fun state(unlocked: Boolean = true, created: Boolean = false) = LockUiState(unlocked = unlocked, created = created) @Test fun created_vault_always_routes_to_recovery_setup_regardless_of_autofill() { // A freshly created vault must walk through recovery-word setup before // the list — even if an autofill nav request is queued, the user just // made the vault and the recovery phrase is the immediate priority. val dest = PeachNav.VaultList // any sentinel; must not be consulted assertEquals( UnlockRoute.RecoverySetup, routeOnUnlock(state(created = true), autofillPending = false, shortcutPending = false) { dest }, ) assertEquals( UnlockRoute.RecoverySetup, routeOnUnlock(state(created = true), autofillPending = true, shortcutPending = false) { dest }, ) } @Test fun autofill_pending_unlock_defers_to_the_navhost() { // The regression: when autofillPending was true at the unlock transition, // LockScreen must NOT route to the post-unlock destination or the NavHost's // editor navigation gets clobbered with popUpTo(0). var postUnlockCalls = 0 val route = routeOnUnlock(state(created = false), autofillPending = true, shortcutPending = false) { postUnlockCalls++ PeachNav.VaultList } assertEquals(UnlockRoute.Defer, route) assertEquals( "Defer must not consult postUnlock — lazy lookup matters when the " + "destination is expensive (lastViewed → resolve → firstActive)", 0, postUnlockCalls, ) } @Test fun launcher_shortcut_unlock_defers_to_the_navhost() { var postUnlockCalls = 0 val route = routeOnUnlock(state(created = false), autofillPending = false, shortcutPending = true) { postUnlockCalls++ PeachNav.VaultList } assertEquals(UnlockRoute.Defer, route) assertEquals(0, postUnlockCalls) } @Test fun ordinary_unlock_routes_through_the_viewmodel_destination() { // The base case: a normal unlock lands on the destination the ViewModel // computed (last-viewed entry, or the vault list, or recovery setup). val expected = PeachNav.EntryDetail("abc-123") val route = routeOnUnlock(state(created = false), autofillPending = false, shortcutPending = false) { expected } assertTrue(route is UnlockRoute.PostUnlock) assertEquals(expected, (route as UnlockRoute.PostUnlock).destination) } @Test fun postUnlock_lookup_runs_exactly_once_on_ordinary_unlock() { // Sanity: postUnlock is invoked exactly once per ordinary unlock so the // ViewModel-side resolution work (which can be non-trivial) is bounded. var calls = 0 val route = routeOnUnlock(state(created = false), autofillPending = false, shortcutPending = false) { calls++ PeachNav.VaultList } assertTrue(route is UnlockRoute.PostUnlock) assertEquals(1, calls) } }