test(overview): Cover the background-monitoring-off signal

Adds monitoringStatus decision tests to OverviewViewModelTest plus Compose
tests for the new dashboard card and the missing-paired-device banner.
This commit is contained in:
darken
2026-08-02 14:51:43 +02:00
committed by Matthias Urhahn
parent 8f0ec5a374
commit d98c118f37
3 changed files with 299 additions and 0 deletions
@@ -0,0 +1,53 @@
package eu.darken.capod.main.ui.overview
import android.content.Context
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import org.junit.Assert.assertTrue
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
class MissingPairedDeviceBannerTest : BaseComposeRobolectricTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
@Test
fun `states the missing paired device and what it costs`() {
composeRule.setContent {
PreviewWrapper {
MissingPairedDeviceBanner(onClick = {})
}
}
composeRule.onAllNodesWithText(context.getString(R.string.overview_card_missing_paired_device))
.assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.overview_card_missing_paired_device_consequence))
.assertCountEquals(1)
}
@Test
fun `tapping the banner invokes the callback`() {
var clicked = false
composeRule.setContent {
PreviewWrapper {
MissingPairedDeviceBanner(onClick = { clicked = true })
}
}
composeRule.onNodeWithText(context.getString(R.string.overview_card_missing_paired_device))
.performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
assertTrue(clicked)
}
}
}
@@ -0,0 +1,112 @@
package eu.darken.capod.main.ui.overview
import android.content.Context
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.pods.core.apple.PodModel
import org.junit.Assert.assertTrue
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
class OverviewScreenMonitoringTest : BaseComposeRobolectricTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private fun state(
effectiveMode: MonitorMode,
address: String?,
) = OverviewViewModel.State(
now = MOCK_NOW,
permissions = emptySet(),
devices = emptyList(),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = effectiveMode,
profiles = listOf(
MockPodDataProvider.profile("My Headphones", PodModel.AIRPODS_PRO2, address = address),
),
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
)
@Test
fun `MANUAL mode states that background monitoring is off`() {
composeRule.setContent {
PreviewWrapper {
OverviewScreen(
state = state(effectiveMode = MonitorMode.MANUAL, address = null),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
onToggleUnmatched = {},
)
}
}
composeRule.onAllNodesWithText(context.getString(R.string.overview_monitoring_off_label))
.assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.overview_monitoring_active_label))
.assertCountEquals(0)
}
@Test
fun `the setup action invokes the callback`() {
var clicked = false
composeRule.setContent {
PreviewWrapper {
OverviewScreen(
state = state(effectiveMode = MonitorMode.MANUAL, address = null),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
onToggleUnmatched = {},
onSetupPairedDevice = { clicked = true },
)
}
}
composeRule.onNodeWithText(context.getString(R.string.overview_monitoring_off_action))
.performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
assertTrue(clicked)
}
}
@Test
fun `AUTOMATIC mode without devices keeps the searching card`() {
composeRule.setContent {
PreviewWrapper {
OverviewScreen(
state = state(effectiveMode = MonitorMode.AUTOMATIC, address = "AA:BB:CC:DD:EE:FF"),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
onToggleUnmatched = {},
)
}
}
composeRule.onAllNodesWithText(context.getString(R.string.overview_monitoring_active_label))
.assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.overview_monitoring_off_label))
.assertCountEquals(0)
}
}
@@ -16,6 +16,7 @@ import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
@@ -208,6 +209,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(profiled, unmatched),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
@@ -235,6 +237,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(profiled, unmatched),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
@@ -257,6 +260,7 @@ class OverviewViewModelTest : BaseTest() {
devices = emptyList(),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
@@ -281,6 +285,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(profiled),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
@@ -307,6 +312,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(device1, device2, device3),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
@@ -333,6 +339,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(device1, device2, device3),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
@@ -361,6 +368,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(device1, device2, device3),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
@@ -387,6 +395,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(device1, device2),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
@@ -413,6 +422,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(profiled1, profiled2, unmatched),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
@@ -450,6 +460,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(unmatched),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
@@ -475,6 +486,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(unmatched),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
@@ -486,6 +498,127 @@ class OverviewViewModelTest : BaseTest() {
}
}
@Nested
inner class MonitoringStatusTests {
private fun addressLessProfile(): DeviceProfile = AppleDeviceProfile(
label = "My Headphones",
model = PodModel.UNKNOWN,
)
private fun addressedProfile(): DeviceProfile = AppleDeviceProfile(
label = "My AirPods",
model = PodModel.AIRPODS_PRO2,
address = "AA:BB:CC:DD:EE:FF",
)
private fun monitoringState(
effectiveMode: MonitorMode,
profiles: List<DeviceProfile>,
devices: List<PodDevice> = emptyList(),
permissions: Set<Permission> = emptySet(),
isBluetoothEnabled: Boolean = true,
) = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = permissions,
devices = devices,
isDebug = false,
isBluetoothEnabled = isBluetoothEnabled,
effectiveMode = effectiveMode,
profiles = profiles,
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
)
@Test
fun `MANUAL with an address-less profile reports background monitoring off`() {
val state = monitoringState(
effectiveMode = MonitorMode.MANUAL,
profiles = listOf(addressLessProfile()),
)
state.monitoringStatus shouldBe OverviewViewModel.MonitoringStatus.BACKGROUND_OFF
}
@Test
fun `MANUAL still reports background monitoring off while an unknown device card shows`() {
// The auto-created wildcard profile can match an unrecognised Apple payload, and that
// card carries neither the missing-paired-device banner nor an edit action.
val unknownDevice = PodDevice(
profileId = "auto-created",
ble = mockk<BlePodSnapshot>(relaxed = true) {
every { model } returns PodModel.UNKNOWN
},
aap = null,
)
val state = monitoringState(
effectiveMode = MonitorMode.MANUAL,
profiles = listOf(addressLessProfile()),
devices = listOf(unknownDevice),
)
state.monitoringStatus shouldBe OverviewViewModel.MonitoringStatus.BACKGROUND_OFF
}
@Test
fun `AUTOMATIC without devices reports searching`() {
val state = monitoringState(
effectiveMode = MonitorMode.AUTOMATIC,
profiles = listOf(addressedProfile()),
)
state.monitoringStatus shouldBe OverviewViewModel.MonitoringStatus.SEARCHING
}
@Test
fun `hidden while a scan-blocking permission is missing`() {
val state = monitoringState(
effectiveMode = MonitorMode.MANUAL,
profiles = listOf(addressLessProfile()),
permissions = setOf(Permission.BLUETOOTH_SCAN),
)
state.monitoringStatus shouldBe OverviewViewModel.MonitoringStatus.HIDDEN
}
@Test
fun `hidden while bluetooth is disabled`() {
val state = monitoringState(
effectiveMode = MonitorMode.MANUAL,
profiles = listOf(addressLessProfile()),
isBluetoothEnabled = false,
)
state.monitoringStatus shouldBe OverviewViewModel.MonitoringStatus.HIDDEN
}
@Test
fun `hidden when there are no profiles at all`() {
val state = monitoringState(
effectiveMode = MonitorMode.MANUAL,
profiles = emptyList(),
)
state.monitoringStatus shouldBe OverviewViewModel.MonitoringStatus.HIDDEN
}
@Test
fun `effectiveMode tracks the resolver`() = runTest(testDispatcher) {
effectiveModeFlow.value = MonitorMode.MANUAL
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(1_000)
latest!!.effectiveMode shouldBe MonitorMode.MANUAL
effectiveModeFlow.value = MonitorMode.ALWAYS
advanceTimeBy(1_000)
latest!!.effectiveMode shouldBe MonitorMode.ALWAYS
}
}
@Nested
inner class WorkerAutolaunchTests {
@@ -751,6 +884,7 @@ class OverviewViewModelTest : BaseTest() {
devices = listOf(device),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,