diff --git a/app/src/test/java/com/matedroid/ui/screens/dashboard/DashboardViewModelTest.kt b/app/src/test/java/com/matedroid/ui/screens/dashboard/DashboardViewModelTest.kt new file mode 100644 index 0000000..4c21772 --- /dev/null +++ b/app/src/test/java/com/matedroid/ui/screens/dashboard/DashboardViewModelTest.kt @@ -0,0 +1,182 @@ +package com.matedroid.ui.screens.dashboard + +import com.matedroid.data.api.models.CarData +import com.matedroid.data.api.models.CarStatus +import com.matedroid.data.repository.ApiResult +import com.matedroid.data.repository.TeslamateRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class DashboardViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var repository: TeslamateRepository + private lateinit var viewModel: DashboardViewModel + + private val testCar = CarData( + carId = 1, + displayName = "Test Tesla", + model = "Model 3", + vin = "TEST123" + ) + + private val testStatus = CarStatus( + carId = 1, + displayName = "Test Tesla", + state = "online", + batteryLevel = 75, + ratedBatteryRangeKm = 300.0, + chargeLimitSoc = 80, + pluggedIn = false, + locked = true + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + repository = mockk() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): DashboardViewModel { + return DashboardViewModel(repository) + } + + @Test + fun `loadCars fetches cars and selects first one`() = runTest { + coEvery { repository.getCars() } returns ApiResult.Success(listOf(testCar)) + coEvery { repository.getCarStatus(1) } returns ApiResult.Success(testStatus) + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + assertEquals(1, viewModel.uiState.value.cars.size) + assertEquals(1, viewModel.uiState.value.selectedCarId) + assertEquals(testStatus, viewModel.uiState.value.carStatus) + } + + @Test + fun `loadCars shows error when api fails`() = runTest { + coEvery { repository.getCars() } returns ApiResult.Error("Network error") + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + assertEquals("Network error", viewModel.uiState.value.error) + assertTrue(viewModel.uiState.value.cars.isEmpty()) + } + + @Test + fun `loadCars handles empty car list`() = runTest { + coEvery { repository.getCars() } returns ApiResult.Success(emptyList()) + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(viewModel.uiState.value.cars.isEmpty()) + assertNull(viewModel.uiState.value.selectedCarId) + } + + @Test + fun `selectCar updates selected car and loads status`() = runTest { + val car1 = CarData(carId = 1, displayName = "Car 1") + val car2 = CarData(carId = 2, displayName = "Car 2") + val status2 = testStatus.copy(carId = 2, displayName = "Car 2") + + coEvery { repository.getCars() } returns ApiResult.Success(listOf(car1, car2)) + coEvery { repository.getCarStatus(1) } returns ApiResult.Success(testStatus) + coEvery { repository.getCarStatus(2) } returns ApiResult.Success(status2) + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.selectedCarId) + + viewModel.selectCar(2) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(2, viewModel.uiState.value.selectedCarId) + assertEquals("Car 2", viewModel.uiState.value.carStatus?.displayName) + } + + @Test + fun `refresh reloads car status`() = runTest { + coEvery { repository.getCars() } returns ApiResult.Success(listOf(testCar)) + coEvery { repository.getCarStatus(1) } returns ApiResult.Success(testStatus) + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + val updatedStatus = testStatus.copy(batteryLevel = 80) + coEvery { repository.getCarStatus(1) } returns ApiResult.Success(updatedStatus) + + viewModel.refresh() + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isRefreshing) + assertEquals(80, viewModel.uiState.value.carStatus?.batteryLevel) + + coVerify(exactly = 2) { repository.getCarStatus(1) } + } + + @Test + fun `refresh does nothing when no car selected`() = runTest { + coEvery { repository.getCars() } returns ApiResult.Success(emptyList()) + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.refresh() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { repository.getCarStatus(any()) } + } + + @Test + fun `clearError clears error state`() = runTest { + coEvery { repository.getCars() } returns ApiResult.Error("Test error") + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals("Test error", viewModel.uiState.value.error) + + viewModel.clearError() + + assertNull(viewModel.uiState.value.error) + } + + @Test + fun `status error is shown when status fetch fails`() = runTest { + coEvery { repository.getCars() } returns ApiResult.Success(listOf(testCar)) + coEvery { repository.getCarStatus(1) } returns ApiResult.Error("Status error") + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals("Status error", viewModel.uiState.value.error) + assertNull(viewModel.uiState.value.carStatus) + } +} diff --git a/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt b/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000..924faad --- /dev/null +++ b/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt @@ -0,0 +1,194 @@ +package com.matedroid.ui.screens.settings + +import com.matedroid.data.local.AppSettings +import com.matedroid.data.local.SettingsDataStore +import com.matedroid.data.repository.ApiResult +import com.matedroid.data.repository.TeslamateRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var settingsDataStore: SettingsDataStore + private lateinit var repository: TeslamateRepository + private lateinit var viewModel: SettingsViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + settingsDataStore = mockk() + repository = mockk() + + every { settingsDataStore.settings } returns flowOf(AppSettings()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): SettingsViewModel { + return SettingsViewModel(settingsDataStore, repository) + } + + @Test + fun `initial state loads settings from datastore`() = runTest { + val savedSettings = AppSettings(serverUrl = "https://test.com", apiToken = "token123") + every { settingsDataStore.settings } returns flowOf(savedSettings) + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals("https://test.com", viewModel.uiState.value.serverUrl) + assertEquals("token123", viewModel.uiState.value.apiToken) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `updateServerUrl updates state`() = runTest { + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("https://new-server.com") + + assertEquals("https://new-server.com", viewModel.uiState.value.serverUrl) + } + + @Test + fun `updateApiToken updates state`() = runTest { + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateApiToken("new-token") + + assertEquals("new-token", viewModel.uiState.value.apiToken) + } + + @Test + fun `testConnection fails with blank url`() = runTest { + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("") + viewModel.testConnection() + testDispatcher.scheduler.advanceUntilIdle() + + val result = viewModel.uiState.value.testResult + assertTrue(result is TestResult.Failure) + assertEquals("Server URL is required", (result as TestResult.Failure).message) + } + + @Test + fun `testConnection fails with invalid url scheme`() = runTest { + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("ftp://invalid.com") + viewModel.testConnection() + testDispatcher.scheduler.advanceUntilIdle() + + val result = viewModel.uiState.value.testResult + assertTrue(result is TestResult.Failure) + assertEquals("URL must start with http:// or https://", (result as TestResult.Failure).message) + } + + @Test + fun `testConnection succeeds with valid url`() = runTest { + coEvery { repository.testConnection(any()) } returns ApiResult.Success(Unit) + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("https://valid.com") + viewModel.testConnection() + testDispatcher.scheduler.advanceUntilIdle() + + val result = viewModel.uiState.value.testResult + assertTrue(result is TestResult.Success) + } + + @Test + fun `testConnection shows failure when api returns error`() = runTest { + coEvery { repository.testConnection(any()) } returns ApiResult.Error("Connection refused") + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("https://unreachable.com") + viewModel.testConnection() + testDispatcher.scheduler.advanceUntilIdle() + + val result = viewModel.uiState.value.testResult + assertTrue(result is TestResult.Failure) + assertEquals("Connection refused", (result as TestResult.Failure).message) + } + + @Test + fun `saveSettings calls datastore and triggers callback`() = runTest { + coEvery { settingsDataStore.saveSettings(any(), any()) } returns Unit + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("https://saved.com") + viewModel.updateApiToken("saved-token") + + var callbackCalled = false + viewModel.saveSettings { callbackCalled = true } + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { settingsDataStore.saveSettings("https://saved.com", "saved-token") } + assertTrue(callbackCalled) + } + + @Test + fun `saveSettings fails with blank url`() = runTest { + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("") + + var callbackCalled = false + viewModel.saveSettings { callbackCalled = true } + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(callbackCalled) + assertEquals("Server URL is required", viewModel.uiState.value.error) + } + + @Test + fun `clearTestResult clears test result`() = runTest { + coEvery { repository.testConnection(any()) } returns ApiResult.Success(Unit) + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("https://test.com") + viewModel.testConnection() + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.uiState.value.testResult is TestResult.Success) + + viewModel.clearTestResult() + + assertNull(viewModel.uiState.value.testResult) + } +}