test(#291): add coverage for 3 critical untested components (#300)

* test(#291): add coverage for 3 critical untested components

- NavigationController dedup guard (line 45): 14 pure unit tests for
  navigateTo, resetTo, goBack, primary-screen clearing, and null guards.
- NotificationReplyReceiver: 11 MockK-based tests covering the full
  WS+Room+notification flow: valid reply sends WS message, persists to
  Room with correct entity shape, shows replied confirmation notification,
  finishes PendingResult, and gracefully handles null/blank inputs and
  Room failures.
- ChatNotificationService isAppInForeground flag: 5 tests verifying
  companion object toggle, volatile modifier (thread safety), default
  false state, and idempotent calls.
- Fix: add @Volatile to isAppInForeground (was a plain var read from
  background coroutine and written from UI thread)

Closes #291

* fix(#291): fix MockK type errors in test compilation

* fix(#291): fix test assertions and goAsync mock for CI

* fix(#291): wrap final BroadcastReceiver.goAsync() with open Kotlin wrapper

* chore: trigger CI on latest fix commit

* fix(#291): make NotificationReplyReceiver open so tests can subclass it

* fix(#291): extract buildReplyNotification() to avoid 'not mocked' framework error

NotificationCompat.Builder.build() internally calls android.app.Notification.Builder
methods (e.g. setWhen) that throw RuntimeException with 'Method setWhen in
android.app.Notification.Builder not mocked' in unit tests — the standard Android
unit test environment does not provide real framework implementations.

Extracting notification creation into an open method (same pattern as goAsyncCompat())
lets tests override it to return a mock Notification, avoiding the framework call
entirely.

* style: fix ktlint blank-line-before-declaration in test

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-22 06:49:19 +03:00
1 parent bbe3b00ccb
commit c44f4c3068
5 files changed
+632 -11

No files matched your search

@@ -0,0 +1,229 @@
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
}
@After
fun tearDown() {
NavigationController.backStack = null
}
// ── 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<NavKey>(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<NavKey>(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<NavKey>(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<NavKey>(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<NavKey>(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())
}
// ── resetTo: atomic clear + navigate ──────────────────────────────────
@Test
fun `resetTo clears the stack and sets the target screen`() {
val backStack = NavBackStack<NavKey>(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<NavKey>(ChatScreen)
NavigationController.backStack = backStack
NavigationController.navigateTo(ProfilesScreen)
assertEquals(2, backStack.size)
NavigationController.goBack()
assertEquals(1, backStack.size)
assertEquals(ChatScreen, backStack.lastOrNull())
}
@Test
fun `goBack falls back to default screen when stack has one item`() {
val backStack = NavBackStack<NavKey>(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<NavKey>(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 and updatePrimaryScreens ───────────────────────────
@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))
}
@Test
fun `updatePrimaryScreens replaces the full screen set`() {
NavigationController.updatePrimaryScreens(setOf(ProfilesScreen, SystemScreen))
assertTrue("ProfilesScreen should now be primary", NavigationController.isPrimaryScreen(ProfilesScreen))
assertTrue("SystemScreen should still be primary", NavigationController.isPrimaryScreen(SystemScreen))
// Old defaults should no longer be primary
assertFalse("ChatScreen should no longer be primary", NavigationController.isPrimaryScreen(ChatScreen))
assertFalse("SkillsScreen should no longer be primary", NavigationController.isPrimaryScreen(SkillsScreen))
assertFalse("SettingsScreen should no longer be primary", NavigationController.isPrimaryScreen(SettingsScreen))
// Reset to defaults to avoid test pollution
NavigationController.updatePrimaryScreens(
setOf(ChatScreen, SkillsScreen, CronJobsScreen, SystemScreen, SettingsScreen),
)
}
}