package com.m57.hermescontrol import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test /** * Unit tests for [NavigationController] — the central navigation guard that * prevents duplicate screen entries on the back stack. * * Issue #291 (Critical Test Coverage): Verifies the deduplication logic at * line 45 (`if (stack.lastOrNull() == key) return`) and the primary-screen * stack-clearing behavior at line 47-49. * * All tests are pure Kotlin with no Android dependencies — they only exercise * [NavigationController]'s companion object methods against a real * [NavBackStack] instance. */ class NavigationControllerTest { @Before fun setUp() { // Start fresh — no pinned back stack from a previous test NavigationController.backStack = null NavigationController.consumePendingSessionId() } @After fun tearDown() { NavigationController.backStack = null NavigationController.consumePendingSessionId() } // ── Dedup guard: navigateTo with same key ────────────────────────────── @Test fun `navigateTo with null backStack does nothing`() { NavigationController.backStack = null NavigationController.navigateTo(ChatScreen) assertNull("backStack should remain null when not initialised", NavigationController.backStack) } @Test fun `navigateTo on same primary screen is a no-op`() { val backStack = NavBackStack(SkillsScreen) NavigationController.backStack = backStack val sizeBefore = backStack.size NavigationController.navigateTo(SkillsScreen) assertEquals("stack size should not change when navigating to the same screen", sizeBefore, backStack.size) assertEquals("top of stack should still be SkillsScreen", SkillsScreen, backStack.lastOrNull()) } @Test fun `navigateTo on same non-primary screen is a no-op`() { val backStack = NavBackStack(ProfilesScreen) NavigationController.backStack = backStack // ProfilesScreen is NOT in the default primaryScreens so it stays on the stack NavigationController.navigateTo(ProfilesScreen) assertEquals(1, backStack.size) assertEquals(ProfilesScreen, backStack.lastOrNull()) } // ── Primary-screen behaviour: stack clearing ────────────────────────── @Test fun `navigateTo on a different primary screen clears the stack`() { val backStack = NavBackStack(ChatScreen) NavigationController.backStack = backStack // Navigate to a non-primary screen first (should add to stack) NavigationController.navigateTo(LogsScreen) assertEquals(2, backStack.size) // Now navigate to a different primary screen — must clear NavigationController.navigateTo(SkillsScreen) assertEquals("primary screen navigation should clear the stack", 1, backStack.size) assertEquals(SkillsScreen, backStack.lastOrNull()) } @Test fun `navigateTo on the current primary screen clears the stack`() { val backStack = NavBackStack(ChatScreen) NavigationController.backStack = backStack // Add a non-primary screen NavigationController.navigateTo(ProfilesScreen) assertEquals(2, backStack.size) // Navigate to ChatScreen — it's a primary screen, so the stack gets // cleared before adding it. The dedup guard only fires when the key // is already the LAST item (ChatScreen is at index 0, ProfilesScreen // is last), not when it's merely present somewhere in the stack. NavigationController.navigateTo(ChatScreen) // Primary screen navigation clears the stack assertEquals("primary navigation clears stack", 1, backStack.size) assertEquals(ChatScreen, backStack.lastOrNull()) } // ── Non-primary screen behaviour: stack appending ───────────────────── @Test fun `navigateTo on a non-primary screen appends to the stack`() { val backStack = NavBackStack(ChatScreen) NavigationController.backStack = backStack NavigationController.navigateTo(ProfilesScreen) assertEquals(2, backStack.size) assertEquals(ProfilesScreen, backStack.lastOrNull()) NavigationController.navigateTo(KeysScreen) assertEquals(3, backStack.size) assertEquals(KeysScreen, backStack.lastOrNull()) } @Test fun `openChatSession navigates once and exposes a consumable session id`() { val backStack = NavBackStack(HistoryScreen) NavigationController.backStack = backStack NavigationController.openChatSession("stored-session") assertEquals(ChatScreen, backStack.lastOrNull()) assertEquals("stored-session", NavigationController.consumePendingSessionId()) assertNull(NavigationController.consumePendingSessionId()) } @Test fun `openChatSession ignores blank ids`() { val backStack = NavBackStack(HistoryScreen) NavigationController.backStack = backStack NavigationController.openChatSession(" ") assertEquals(HistoryScreen, backStack.lastOrNull()) assertNull(NavigationController.consumePendingSessionId()) } // ── resetTo: atomic clear + navigate ────────────────────────────────── @Test fun `resetTo clears the stack and sets the target screen`() { val backStack = NavBackStack(ChatScreen) NavigationController.backStack = backStack NavigationController.navigateTo(ProfilesScreen) NavigationController.navigateTo(KeysScreen) assertEquals(3, backStack.size) NavigationController.resetTo(SettingsScreen) assertEquals(1, backStack.size) assertEquals(SettingsScreen, backStack.lastOrNull()) } @Test fun `resetTo with null backStack does nothing`() { NavigationController.backStack = null NavigationController.resetTo(ChatScreen) assertNull(NavigationController.backStack) } // ── goBack: never leave the stack empty ─────────────────────────────── @Test fun `goBack removes the top screen when stack has more than one`() { val backStack = NavBackStack(ChatScreen) NavigationController.backStack = backStack NavigationController.navigateTo(ProfilesScreen) assertEquals(2, backStack.size) NavigationController.goBack() assertEquals(1, backStack.size) assertEquals(ChatScreen, backStack.lastOrNull()) } @Test fun `goBack from chat opens history`() { val backStack = NavBackStack(ChatScreen) NavigationController.backStack = backStack NavigationController.goBack() assertEquals(1, backStack.size) assertEquals(HistoryScreen, backStack.lastOrNull()) } @Test fun `goBack falls back to default screen when stack has one item`() { val backStack = NavBackStack(ProfilesScreen) NavigationController.backStack = backStack NavigationController.goBack() assertEquals(1, backStack.size) assertEquals("default fallback should be ChatScreen", ChatScreen, backStack.lastOrNull()) } @Test fun `goBack with custom fallback uses the given screen`() { val backStack = NavBackStack(ProfilesScreen) NavigationController.backStack = backStack NavigationController.goBack(fallback = SkillsScreen) assertEquals(1, backStack.size) assertEquals(SkillsScreen, backStack.lastOrNull()) } @Test fun `goBack with null backStack does nothing`() { NavigationController.backStack = null NavigationController.goBack() assertNull(NavigationController.backStack) } // ── primaryScreens ──────────────────────────────────────────────────── @Test fun `isPrimaryScreen returns true for default screens`() { assertTrue("ChatScreen should be primary by default", NavigationController.isPrimaryScreen(ChatScreen)) assertTrue("SkillsScreen should be primary by default", NavigationController.isPrimaryScreen(SkillsScreen)) assertTrue("CronJobsScreen should be primary by default", NavigationController.isPrimaryScreen(CronJobsScreen)) assertTrue("SystemScreen should be primary by default", NavigationController.isPrimaryScreen(SystemScreen)) assertTrue("SettingsScreen should be primary by default", NavigationController.isPrimaryScreen(SettingsScreen)) } @Test fun `isPrimaryScreen returns false for non-default screens`() { assertFalse("ProfilesScreen should NOT be primary", NavigationController.isPrimaryScreen(ProfilesScreen)) assertFalse("LogsScreen should NOT be primary", NavigationController.isPrimaryScreen(LogsScreen)) assertFalse("ConfigScreen should NOT be primary", NavigationController.isPrimaryScreen(ConfigScreen)) } }