diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/MissingPairedDeviceBannerTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/MissingPairedDeviceBannerTest.kt new file mode 100644 index 00000000..bd34cd84 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/MissingPairedDeviceBannerTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewScreenMonitoringTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewScreenMonitoringTest.kt new file mode 100644 index 00000000..8bd45bda --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewScreenMonitoringTest.kt @@ -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) + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index b57a0cc5..bbd45fde 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -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, + devices: List = emptyList(), + permissions: Set = 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(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,