test: Cover nudge persistence, permission gating, and autolaunch reactivity

This commit is contained in:
Matthias Urhahn
2026-05-07 14:10:16 +02:00
committed by Matthias Urhahn
parent a4ba302223
commit 1eeeb63a76
6 changed files with 330 additions and 31 deletions
@@ -0,0 +1,117 @@
package eu.darken.capod.common.bluetooth
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.datastore.FakeDataStoreValue
class NudgeCapabilityStoreTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var fakePersisted: FakeDataStoreValue<NudgeAvailability>
@BeforeEach
fun setup() {
Dispatchers.setMain(testDispatcher)
fakePersisted = FakeDataStoreValue(NudgeAvailability.UNKNOWN)
}
@AfterEach
fun teardown() {
Dispatchers.resetMain()
}
private fun createStore() = NudgeCapabilityStore(
persistedValue = fakePersisted.mock,
appScope = kotlinx.coroutines.CoroutineScope(testDispatcher),
dispatcherProvider = TestDispatcherProvider(testDispatcher),
)
@Test
fun `verdictFor maps Accepted to AVAILABLE`() {
NudgeCapabilityStore.verdictFor(NudgeAttemptResult.Accepted) shouldBe NudgeAvailability.AVAILABLE
}
@Test
fun `verdictFor maps UnavailableHiddenApi to BROKEN`() {
NudgeCapabilityStore.verdictFor(NudgeAttemptResult.UnavailableHiddenApi) shouldBe NudgeAvailability.BROKEN
}
@Test
fun `verdictFor returns null for Rejected`() {
NudgeCapabilityStore.verdictFor(NudgeAttemptResult.Rejected) shouldBe null
}
@Test
fun `verdictFor returns null for UnavailableMissingPermission`() {
NudgeCapabilityStore.verdictFor(NudgeAttemptResult.UnavailableMissingPermission) shouldBe null
}
@Test
fun `record Accepted persists AVAILABLE`() = runTest(testDispatcher) {
val store = createStore()
store.record(NudgeAttemptResult.Accepted)
fakePersisted.value shouldBe NudgeAvailability.AVAILABLE
store.availability.value shouldBe NudgeAvailability.AVAILABLE
}
@Test
fun `record UnavailableHiddenApi persists BROKEN`() = runTest(testDispatcher) {
val store = createStore()
store.record(NudgeAttemptResult.UnavailableHiddenApi)
fakePersisted.value shouldBe NudgeAvailability.BROKEN
}
@Test
fun `record Rejected does not persist (stays UNKNOWN)`() = runTest(testDispatcher) {
val store = createStore()
store.record(NudgeAttemptResult.Rejected)
fakePersisted.value shouldBe NudgeAvailability.UNKNOWN
store.availability.value shouldBe NudgeAvailability.UNKNOWN
}
@Test
fun `record UnavailableMissingPermission does not persist`() = runTest(testDispatcher) {
// Pre-existing AVAILABLE — a transient permission failure should not flip it to BROKEN.
fakePersisted.value = NudgeAvailability.AVAILABLE
val store = createStore()
store.record(NudgeAttemptResult.UnavailableMissingPermission)
fakePersisted.value shouldBe NudgeAvailability.AVAILABLE
}
@Test
fun `availability StateFlow reflects persisted value on construction`() = runTest(testDispatcher) {
fakePersisted.value = NudgeAvailability.BROKEN
val store = createStore()
store.availability.value shouldBe NudgeAvailability.BROKEN
}
@Test
fun `availability StateFlow tracks persisted updates`() = runTest(testDispatcher) {
val store = createStore()
store.availability.value shouldBe NudgeAvailability.UNKNOWN
fakePersisted.value = NudgeAvailability.AVAILABLE
store.availability.value shouldBe NudgeAvailability.AVAILABLE
}
}
@@ -0,0 +1,102 @@
package eu.darken.capod.main.core
import eu.darken.capod.common.permissions.Permission
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class PermissionToolTest : BaseTest() {
@Test
fun `IGNORE_BATTERY_OPTIMIZATION is applicable only in ALWAYS mode`() {
PermissionTool.isApplicable(
Permission.IGNORE_BATTERY_OPTIMIZATION,
MonitorMode.ALWAYS,
anyPopupEnabled = false,
) shouldBe true
PermissionTool.isApplicable(
Permission.IGNORE_BATTERY_OPTIMIZATION,
MonitorMode.AUTOMATIC,
anyPopupEnabled = false,
) shouldBe false
PermissionTool.isApplicable(
Permission.IGNORE_BATTERY_OPTIMIZATION,
MonitorMode.MANUAL,
anyPopupEnabled = false,
) shouldBe false
}
@Test
fun `ACCESS_BACKGROUND_LOCATION is applicable only in ALWAYS mode`() {
PermissionTool.isApplicable(
Permission.ACCESS_BACKGROUND_LOCATION,
MonitorMode.ALWAYS,
anyPopupEnabled = false,
) shouldBe true
PermissionTool.isApplicable(
Permission.ACCESS_BACKGROUND_LOCATION,
MonitorMode.AUTOMATIC,
anyPopupEnabled = false,
) shouldBe false
PermissionTool.isApplicable(
Permission.ACCESS_BACKGROUND_LOCATION,
MonitorMode.MANUAL,
anyPopupEnabled = false,
) shouldBe false
}
@Test
fun `SYSTEM_ALERT_WINDOW is applicable only when popups are enabled`() {
PermissionTool.isApplicable(
Permission.SYSTEM_ALERT_WINDOW,
MonitorMode.AUTOMATIC,
anyPopupEnabled = true,
) shouldBe true
PermissionTool.isApplicable(
Permission.SYSTEM_ALERT_WINDOW,
MonitorMode.ALWAYS,
anyPopupEnabled = false,
) shouldBe false
}
@Test
fun `popup permission gating is independent of monitor mode`() {
// SYSTEM_ALERT_WINDOW gating depends on popups, not on mode.
for (mode in MonitorMode.entries) {
PermissionTool.isApplicable(
Permission.SYSTEM_ALERT_WINDOW,
mode,
anyPopupEnabled = true,
) shouldBe true
}
}
@Test
fun `mode-gated permissions don't depend on popup state`() {
// IGNORE_BATTERY_OPTIMIZATION and ACCESS_BACKGROUND_LOCATION gate on mode only.
listOf(Permission.IGNORE_BATTERY_OPTIMIZATION, Permission.ACCESS_BACKGROUND_LOCATION).forEach { perm ->
PermissionTool.isApplicable(perm, MonitorMode.ALWAYS, anyPopupEnabled = true) shouldBe true
PermissionTool.isApplicable(perm, MonitorMode.ALWAYS, anyPopupEnabled = false) shouldBe true
PermissionTool.isApplicable(perm, MonitorMode.AUTOMATIC, anyPopupEnabled = true) shouldBe false
PermissionTool.isApplicable(perm, MonitorMode.AUTOMATIC, anyPopupEnabled = false) shouldBe false
}
}
@Test
fun `unconditional permissions are always applicable`() {
// BLUETOOTH_SCAN, BLUETOOTH_CONNECT, ACCESS_FINE_LOCATION, POST_NOTIFICATIONS, etc.
// are not mode/popup-gated.
val unconditional = Permission.entries.filter {
it != Permission.IGNORE_BATTERY_OPTIMIZATION &&
it != Permission.ACCESS_BACKGROUND_LOCATION &&
it != Permission.SYSTEM_ALERT_WINDOW
}
for (perm in unconditional) {
for (mode in MonitorMode.entries) {
for (popups in listOf(true, false)) {
PermissionTool.isApplicable(perm, mode, popups) shouldBe true
}
}
}
}
}
@@ -21,6 +21,7 @@ import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -404,6 +405,51 @@ class OverviewViewModelTest : BaseTest() {
verify(exactly = 0) { monitorControl.startMonitor(any()) }
}
@Test
fun `mode transition AUTOMATIC to ALWAYS triggers startMonitor`() = runTest(testDispatcher) {
// Regression guard: the old impl only re-evaluated on missingScanPermissions emissions,
// so toggling auto-connect (which flips effective mode AUTOMATIC -> ALWAYS) wouldn't
// actually start the monitor. The combine() over effectiveMode must keep it reactive.
effectiveModeFlow.value = MonitorMode.AUTOMATIC
connectedDevicesFlow.value = emptyList()
val vm = createViewModel()
// Subscribe so the workerAutolaunch onEach actually runs.
val collectJob = launch { vm.workerAutolaunch.collect {} }
advanceUntilIdle()
verify(exactly = 0) { monitorControl.startMonitor(any()) }
// Flip to ALWAYS — should start the monitor without any other input changing.
effectiveModeFlow.value = MonitorMode.ALWAYS
advanceUntilIdle()
verify(exactly = 1) { monitorControl.startMonitor(any()) }
collectJob.cancel()
}
@Test
fun `connected device appearing in AUTOMATIC triggers startMonitor`() = runTest(testDispatcher) {
// Symmetric to the mode-transition test: flipping connectedDevices from empty
// to non-empty under AUTOMATIC must also trigger autolaunch.
effectiveModeFlow.value = MonitorMode.AUTOMATIC
connectedDevicesFlow.value = emptyList()
val vm = createViewModel()
val collectJob = launch { vm.workerAutolaunch.collect {} }
advanceUntilIdle()
verify(exactly = 0) { monitorControl.startMonitor(any()) }
connectedDevicesFlow.value = listOf(mockk(relaxed = true))
advanceUntilIdle()
verify(exactly = 1) { monitorControl.startMonitor(any()) }
collectJob.cancel()
}
}
@Nested