diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19e0093..a0ac25b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,10 @@ name: Build APK on: push: - branches: [ main, dev] + branches: [ main, dev ] + pull_request: + branches: [ main, dev, feature/* ] + types: [opened, synchronize, reopened] jobs: build: @@ -17,23 +20,24 @@ jobs: with: java-version: '17' distribution: 'temurin' - - - name: Cache Gradle packages - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - + - name: Grant execute permission for gradlew run: chmod +x gradlew + + - name: Run unit tests + run: ./gradlew test --continue - - name: Run tests - run: ./gradlew test - + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ github.run_number }} + path: | + app/build/reports/tests/ + app/build/test-results/ + app/build/reports/androidTests/ + retention-days: 7 + - name: Build debug APK run: ./gradlew assembleDebug @@ -42,14 +46,4 @@ jobs: with: name: debug-apk path: app/build/outputs/apk/debug/*.apk - retention-days: 30 - - - name: Build release APK - run: ./gradlew assembleRelease - - - name: Upload release APK - uses: actions/upload-artifact@v4 - with: - name: release-apk - path: app/build/outputs/apk/release/*.apk - retention-days: 30 + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml deleted file mode 100644 index 43fc52c..0000000 --- a/.github/workflows/build_pull_request.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Build PR -on: - pull_request: - branches: - - '**' - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: 17 - distribution: "zulu" - cache: 'gradle' - - - name: Build debug APK - run: ./gradlew assembleDebug - env: - PULL_REQUEST: 'true' - - - name: Upload APK - uses: actions/upload-artifact@v4 - with: - name: app - path: app/build/outputs/apk/full/debug/*.apk \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0d9f42..2b2d864 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,16 +29,6 @@ jobs: java-version: '17' distribution: 'temurin' - - name: Cache Gradle packages - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/README.md b/README.md index 7b85694..c3ee1c6 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,8 @@ settings.gradle.kts # Project settings ```bash # Clone the repository -git clone https://github.com/aunchagaonkar/Network-Switch.git -cd Network-Switch +git clone https://github.com/aunchagaonkar/NetworkSwitch.git +cd NetworkSwitch # Build debug APK ./gradlew assembleDebug @@ -117,7 +117,7 @@ cd Network-Switch ``` ## TODO -- [ ] Add unit tests for all core components +- [x] Add unit tests for all core components - [ ] Add network speed monitoring - [ ] Implement network statistics tracking - [ ] Add support for 3G fallback modes @@ -147,6 +147,14 @@ Contributions are welcome! Please follow these guidelines: - Follow Material Design guidelines for UI changes ### Testing +This project includes a suite of unit tests to ensure code quality and stability. + +To run the unit tests, execute the following command from the root of the project: + +```bash +./gradlew test +``` + - Add unit tests for new functionality - Test on both rooted and non-rooted devices - Verify compatibility across different Android versions diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 137c6f7..4098045 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,7 +26,7 @@ android { // Resource optimization resourceConfigurations += listOf("en", "xxhdpi") - // Disable unnecessary features for smaller APK + // Disable unnecessary features vectorDrawables { useSupportLibrary = true } @@ -82,6 +82,14 @@ android { kotlinCompilerExtensionVersion = libs.versions.compose.get() } + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + animationsDisabled = true + } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -108,6 +116,9 @@ android { // JNI libraries optimization jniLibs { useLegacyPackaging = false + // Exclude native libraries from packaging + excludes += "**/libandroidx.graphics.path.so" + excludes += "**/libdatastore_shared_counter.so" } } @@ -161,6 +172,8 @@ dependencies { // Testing testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) @@ -168,4 +181,7 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + // Hilt Testing + testImplementation(libs.hilt.android.testing) + kaptTest(libs.hilt.compiler) } diff --git a/app/src/test/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImplTest.kt b/app/src/test/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImplTest.kt new file mode 100644 index 0000000..cf040b4 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImplTest.kt @@ -0,0 +1,127 @@ +package com.supernova.networkswitch.data.repository + +import com.supernova.networkswitch.data.source.RootNetworkControlDataSource +import com.supernova.networkswitch.data.source.ShizukuNetworkControlDataSource +import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.util.CoroutineTestRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import android.telephony.SubscriptionManager +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class NetworkControlRepositoryImplTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var rootDataSource: RootNetworkControlDataSource + private lateinit var shizukuDataSource: ShizukuNetworkControlDataSource + private lateinit var preferencesRepository: PreferencesRepository + private lateinit var repository: NetworkControlRepositoryImpl + + @Before + fun setUp() { + rootDataSource = mockk(relaxed = true) + shizukuDataSource = mockk(relaxed = true) + preferencesRepository = mockk(relaxed = true) + + mockkStatic(SubscriptionManager::class) + every { SubscriptionManager.getDefaultDataSubscriptionId() } returns 1 + + repository = NetworkControlRepositoryImpl( + rootDataSource, + shizukuDataSource, + preferencesRepository + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `checkCompatibility uses RootDataSource when method is ROOT`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.ROOT + + repository.checkCompatibility(ControlMethod.ROOT) + + coVerify { rootDataSource.checkCompatibility(1) } + coVerify(exactly = 0) { shizukuDataSource.checkCompatibility(any()) } + } + + @Test + fun `checkCompatibility uses ShizukuDataSource when method is SHIZUKU`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.SHIZUKU + + repository.checkCompatibility(ControlMethod.SHIZUKU) + + coVerify { shizukuDataSource.checkCompatibility(1) } + coVerify(exactly = 0) { rootDataSource.checkCompatibility(any()) } + } + + @Test + fun `getFivegEnabled uses RootDataSource when method is ROOT`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.ROOT + val subId = 1 + + repository.getFivegEnabled(subId) + + coVerify { rootDataSource.getFivegEnabled(subId) } + coVerify(exactly = 0) { shizukuDataSource.getFivegEnabled(any()) } + } + + @Test + fun `getFivegEnabled uses ShizukuDataSource when method is SHIZUKU`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.SHIZUKU + val subId = 1 + + repository.getFivegEnabled(subId) + + coVerify { shizukuDataSource.getFivegEnabled(subId) } + coVerify(exactly = 0) { rootDataSource.getFivegEnabled(any()) } + } + + @Test + fun `setFivegEnabled uses RootDataSource when method is ROOT`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.ROOT + val subId = 1 + val enabled = true + + repository.setFivegEnabled(subId, enabled) + + coVerify { rootDataSource.setFivegEnabled(subId, enabled) } + coVerify(exactly = 0) { shizukuDataSource.setFivegEnabled(any(), any()) } + } + + @Test + fun `setFivegEnabled uses ShizukuDataSource when method is SHIZUKU`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.SHIZUKU + val subId = 1 + val enabled = true + + repository.setFivegEnabled(subId, enabled) + + coVerify { shizukuDataSource.setFivegEnabled(subId, enabled) } + coVerify(exactly = 0) { rootDataSource.setFivegEnabled(any(), any()) } + } + + @Test + fun `resetConnections calls reset on both data sources`() = runTest { + repository.resetConnections() + + coVerify { rootDataSource.resetConnection() } + coVerify { shizukuDataSource.resetConnection() } + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImplTest.kt b/app/src/test/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImplTest.kt new file mode 100644 index 0000000..c8d9a4b --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImplTest.kt @@ -0,0 +1,48 @@ +package com.supernova.networkswitch.data.repository + +import com.supernova.networkswitch.data.source.PreferencesDataSource +import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.util.CoroutineTestRule +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class PreferencesRepositoryImplTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var mockDataSource: PreferencesDataSource + private lateinit var repository: PreferencesRepositoryImpl + + @Before + fun setUp() { + mockDataSource = mockk(relaxed = true) + repository = PreferencesRepositoryImpl(mockDataSource) + } + + @Test + fun `getControlMethod calls data source`() = runTest { + repository.getControlMethod() + coVerify { mockDataSource.getControlMethod() } + } + + @Test + fun `setControlMethod calls data source`() = runTest { + val method = ControlMethod.ROOT + repository.setControlMethod(method) + coVerify { mockDataSource.setControlMethod(method) } + } + + @Test + fun `observeControlMethod calls data source`() { + repository.observeControlMethod() + verify { mockDataSource.observeControlMethod() } + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/data/source/PreferencesDataSourceTest.kt b/app/src/test/java/com/supernova/networkswitch/data/source/PreferencesDataSourceTest.kt new file mode 100644 index 0000000..718330d --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/data/source/PreferencesDataSourceTest.kt @@ -0,0 +1,140 @@ +package com.supernova.networkswitch.data.source + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.util.CoroutineTestRule +import io.mockk.coEvery +import androidx.datastore.preferences.core.edit +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class PreferencesDataSourceTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var mockDataStore: DataStore + private lateinit var preferencesDataSource: PreferencesDataSource + + private val controlMethodKey = stringPreferencesKey("control_method") + + @Before + fun setUp() { + mockDataStore = mockk(relaxed = true) + mockkStatic("androidx.datastore.preferences.core.PreferencesKt") + coEvery { mockDataStore.edit(any()) } returns mockk() + preferencesDataSource = PreferencesDataSource(mockDataStore) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `getControlMethod returns ROOT when DataStore has ROOT`() = runTest { + val preferences = mockk() + coEvery { preferences[controlMethodKey] } returns "ROOT" + coEvery { mockDataStore.data } returns flowOf(preferences) + + val result = preferencesDataSource.getControlMethod() + + assertEquals(ControlMethod.ROOT, result) + } + + @Test + fun `getControlMethod returns SHIZUKU when DataStore has SHIZUKU`() = runTest { + val preferences = mockk() + coEvery { preferences[controlMethodKey] } returns "SHIZUKU" + coEvery { mockDataStore.data } returns flowOf(preferences) + + val result = preferencesDataSource.getControlMethod() + + assertEquals(ControlMethod.SHIZUKU, result) + } + + @Test + fun `getControlMethod returns default SHIZUKU when DataStore is empty`() = runTest { + val preferences = mockk() + coEvery { preferences[controlMethodKey] } returns null + coEvery { mockDataStore.data } returns flowOf(preferences) + + val result = preferencesDataSource.getControlMethod() + + assertEquals(ControlMethod.SHIZUKU, result) + } + + @Test + fun `getControlMethod returns default SHIZUKU for invalid value`() = runTest { + val preferences = mockk() + coEvery { preferences[controlMethodKey] } returns "INVALID_VALUE" + coEvery { mockDataStore.data } returns flowOf(preferences) + + val result = preferencesDataSource.getControlMethod() + + assertEquals(ControlMethod.SHIZUKU, result) + } + + @Test + fun `setControlMethod calls edit on DataStore with ROOT`() = runTest { + preferencesDataSource.setControlMethod(ControlMethod.ROOT) + + coVerify { + mockDataStore.edit(any()) + } + } + + @Test + fun `setControlMethod calls edit on DataStore with SHIZUKU`() = runTest { + preferencesDataSource.setControlMethod(ControlMethod.SHIZUKU) + + coVerify { + mockDataStore.edit(any()) + } + } + + @Test + fun `observeControlMethod emits correct values`() = runTest { + val preferencesRoot = mockk() + coEvery { preferencesRoot[controlMethodKey] } returns "ROOT" + + val preferencesShizuku = mockk() + coEvery { preferencesShizuku[controlMethodKey] } returns "SHIZUKU" + + coEvery { mockDataStore.data } returns flowOf(preferencesRoot, preferencesShizuku) + + val results = mutableListOf() + preferencesDataSource.observeControlMethod().collect { + results.add(it) + } + + assertEquals(2, results.size) + assertEquals(ControlMethod.ROOT, results[0]) + assertEquals(ControlMethod.SHIZUKU, results[1]) + } + + @Test + fun `observeControlMethod emits default value when empty`() = runTest { + val emptyPreferences = mockk() + coEvery { emptyPreferences[controlMethodKey] } returns null + coEvery { mockDataStore.data } returns flowOf(emptyPreferences) + + val result = preferencesDataSource.observeControlMethod().first() + + assertEquals(ControlMethod.SHIZUKU, result) + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/NetworkUseCasesTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/NetworkUseCasesTest.kt new file mode 100644 index 0000000..f423d63 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/NetworkUseCasesTest.kt @@ -0,0 +1,125 @@ +package com.supernova.networkswitch.domain.usecase + +import com.supernova.networkswitch.domain.model.CompatibilityState +import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.repository.NetworkControlRepository +import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.util.CoroutineTestRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class NetworkUseCasesTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var networkControlRepository: NetworkControlRepository + private lateinit var preferencesRepository: PreferencesRepository + + private lateinit var checkCompatibilityUseCase: CheckCompatibilityUseCase + private lateinit var toggleNetworkModeUseCase: ToggleNetworkModeUseCase + private lateinit var getNetworkStateUseCase: GetNetworkStateUseCase + private lateinit var updateControlMethodUseCase: UpdateControlMethodUseCase + private lateinit var resetConnectionsUseCase: ResetConnectionsUseCase + + @Before + fun setUp() { + networkControlRepository = mockk(relaxed = true) + preferencesRepository = mockk(relaxed = true) + + checkCompatibilityUseCase = CheckCompatibilityUseCase(networkControlRepository, preferencesRepository) + toggleNetworkModeUseCase = ToggleNetworkModeUseCase(networkControlRepository) + getNetworkStateUseCase = GetNetworkStateUseCase(networkControlRepository) + updateControlMethodUseCase = UpdateControlMethodUseCase(preferencesRepository) + resetConnectionsUseCase = ResetConnectionsUseCase(networkControlRepository) + } + + @Test + fun `CheckCompatibilityUseCase returns state from repository`() = runTest { + val expectedState = CompatibilityState.Compatible + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.ROOT + coEvery { networkControlRepository.checkCompatibility(any()) } returns expectedState + + val result = checkCompatibilityUseCase() + + assertEquals(expectedState, result) + } + + @Test + fun `ToggleNetworkModeUseCase toggles from true to false`() = runTest { + coEvery { networkControlRepository.getFivegEnabled(any()) } returns true + coEvery { networkControlRepository.setFivegEnabled(any(), false) } returns Result.success(Unit) + + val result = toggleNetworkModeUseCase(1) + + coVerify { networkControlRepository.setFivegEnabled(1, false) } + assertTrue(result.isSuccess) + assertEquals(false, result.getOrNull()) + } + + @Test + fun `ToggleNetworkModeUseCase toggles from false to true`() = runTest { + coEvery { networkControlRepository.getFivegEnabled(any()) } returns false + coEvery { networkControlRepository.setFivegEnabled(any(), true) } returns Result.success(Unit) + + val result = toggleNetworkModeUseCase(1) + + coVerify { networkControlRepository.setFivegEnabled(1, true) } + assertTrue(result.isSuccess) + assertEquals(true, result.getOrNull()) + } + + @Test + fun `ToggleNetworkModeUseCase handles failure`() = runTest { + val exception = RuntimeException("Test Exception") + coEvery { networkControlRepository.getFivegEnabled(any()) } throws exception + + val result = toggleNetworkModeUseCase(1) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + @Test + fun `GetNetworkStateUseCase returns state from repository`() = runTest { + coEvery { networkControlRepository.getFivegEnabled(any()) } returns true + + val result = getNetworkStateUseCase(1) + + assertTrue(result.isSuccess) + assertEquals(true, result.getOrNull()) + } + + @Test + fun `GetNetworkStateUseCase handles failure`() = runTest { + val exception = RuntimeException("Test Exception") + coEvery { networkControlRepository.getFivegEnabled(any()) } throws exception + + val result = getNetworkStateUseCase(1) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + @Test + fun `UpdateControlMethodUseCase calls repository`() = runTest { + val method = ControlMethod.SHIZUKU + updateControlMethodUseCase(method) + coVerify { preferencesRepository.setControlMethod(method) } + } + + @Test + fun `ResetConnectionsUseCase calls repository`() = runTest { + resetConnectionsUseCase() + coVerify { networkControlRepository.resetConnections() } + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModelTest.kt b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModelTest.kt new file mode 100644 index 0000000..7ba1e2a --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModelTest.kt @@ -0,0 +1,135 @@ +package com.supernova.networkswitch.presentation.viewmodel + +import com.supernova.networkswitch.domain.model.CompatibilityState +import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.domain.usecase.* +import com.supernova.networkswitch.util.CoroutineTestRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import android.telephony.SubscriptionManager +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class MainViewModelTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var checkCompatibilityUseCase: CheckCompatibilityUseCase + private lateinit var getNetworkStateUseCase: GetNetworkStateUseCase + private lateinit var toggleNetworkModeUseCase: ToggleNetworkModeUseCase + private lateinit var updateControlMethodUseCase: UpdateControlMethodUseCase + private lateinit var preferencesRepository: PreferencesRepository + + private lateinit var viewModel: MainViewModel + + @Before + fun setUp() { + checkCompatibilityUseCase = mockk() + getNetworkStateUseCase = mockk() + toggleNetworkModeUseCase = mockk() + updateControlMethodUseCase = mockk() + preferencesRepository = mockk() + + mockkStatic(SubscriptionManager::class) + every { SubscriptionManager.getDefaultDataSubscriptionId() } returns 1 + + coEvery { preferencesRepository.observeControlMethod() } returns flowOf(ControlMethod.SHIZUKU) + coEvery { checkCompatibilityUseCase() } returns CompatibilityState.Pending + coEvery { getNetworkStateUseCase(any()) } returns Result.success(false) + coEvery { updateControlMethodUseCase(any()) } returns Unit + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun createViewModel() { + viewModel = MainViewModel( + checkCompatibilityUseCase, + getNetworkStateUseCase, + toggleNetworkModeUseCase, + updateControlMethodUseCase, + preferencesRepository + ) + } + + @Test + fun `init calls required methods`() = runTest { + createViewModel() + coVerify { preferencesRepository.observeControlMethod() } + coVerify { checkCompatibilityUseCase() } + coVerify { getNetworkStateUseCase(any()) } + } + + @Test + fun `toggleNetworkMode success updates networkState`() = runTest { + coEvery { toggleNetworkModeUseCase(any()) } returns Result.success(true) + createViewModel() + + viewModel.toggleNetworkMode() + + assertTrue(viewModel.networkState) + assertFalse(viewModel.isLoading) + } + + @Test + fun `toggleNetworkMode failure refreshes network state`() = runTest { + coEvery { toggleNetworkModeUseCase(any()) } returns Result.failure(Exception()) + coEvery { getNetworkStateUseCase(any()) } returns Result.success(false) + createViewModel() + + viewModel.toggleNetworkMode() + + assertFalse(viewModel.networkState) + assertFalse(viewModel.isLoading) + coVerify(exactly = 2) { getNetworkStateUseCase(any()) } // Initial + refresh + } + + @Test + fun `retryCompatibilityCheck calls use case`() = runTest { + createViewModel() + viewModel.retryCompatibilityCheck() + coVerify(exactly = 2) { checkCompatibilityUseCase() } // Initial + retry + } + + @Test + fun `refreshAllData calls required methods`() = runTest { + createViewModel() + viewModel.refreshAllData() + coVerify(exactly = 2) { checkCompatibilityUseCase() } // Initial + refresh + coVerify(exactly = 2) { getNetworkStateUseCase(any()) } // Initial + refresh + } + + @Test + fun `switchToMethod calls use case`() = runTest { + createViewModel() + viewModel.switchToMethod(ControlMethod.ROOT) + coVerify { updateControlMethodUseCase(ControlMethod.ROOT) } + } + + @Test + fun `compatibilityState is updated on check`() = runTest { + val expectedState = CompatibilityState.Compatible + coEvery { checkCompatibilityUseCase() } returns expectedState + + createViewModel() + + assertEquals(expectedState, viewModel.compatibilityState) + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt new file mode 100644 index 0000000..b94b532 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt @@ -0,0 +1,80 @@ +package com.supernova.networkswitch.presentation.viewmodel + +import com.supernova.networkswitch.domain.model.CompatibilityState +import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.repository.NetworkControlRepository +import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.util.CoroutineTestRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class SettingsViewModelTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var preferencesRepository: PreferencesRepository + private lateinit var networkControlRepository: NetworkControlRepository + + private lateinit var viewModel: SettingsViewModel + + @Before + fun setUp() { + preferencesRepository = mockk(relaxed = true) + networkControlRepository = mockk(relaxed = true) + + coEvery { preferencesRepository.observeControlMethod() } returns flowOf(ControlMethod.SHIZUKU) + } + + private fun createViewModel() { + viewModel = SettingsViewModel( + preferencesRepository, + networkControlRepository + ) + } + + @Test + fun `init calls checkAllCompatibility`() = runTest { + createViewModel() + coVerify { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } + coVerify { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } + } + + @Test + fun `checkAllCompatibility updates compatibility states`() = runTest { + val rootState = CompatibilityState.Compatible + val shizukuState = CompatibilityState.Incompatible("Test Reason") + coEvery { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } returns rootState + coEvery { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } returns shizukuState + + createViewModel() + + assertEquals(rootState, viewModel.rootCompatibility) + assertEquals(shizukuState, viewModel.shizukuCompatibility) + } + + @Test + fun `updateControlMethod calls repository`() = runTest { + createViewModel() + val method = ControlMethod.ROOT + viewModel.updateControlMethod(method) + coVerify { preferencesRepository.setControlMethod(method) } + } + + @Test + fun `retryCompatibilityCheck calls checkAllCompatibility`() = runTest { + createViewModel() + viewModel.retryCompatibilityCheck() + coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } + coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/util/CoroutineTestRule.kt b/app/src/test/java/com/supernova/networkswitch/util/CoroutineTestRule.kt new file mode 100644 index 0000000..a954762 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/util/CoroutineTestRule.kt @@ -0,0 +1,26 @@ +package com.supernova.networkswitch.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class CoroutineTestRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/util/UtilsTest.kt b/app/src/test/java/com/supernova/networkswitch/util/UtilsTest.kt new file mode 100644 index 0000000..9993f76 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/util/UtilsTest.kt @@ -0,0 +1,38 @@ +package com.supernova.networkswitch.util + +import com.topjohnwu.superuser.Shell +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class UtilsTest { + + @Before + fun setUp() { + mockkStatic(Shell::class) + every { Shell.getShell() } returns mockk() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `isRootGranted does not throw`() { + every { Shell.isAppGrantedRoot() } returns true + Utils.isRootGranted() // Should not throw + + every { Shell.isAppGrantedRoot() } returns false + Utils.isRootGranted() // Should not throw + + every { Shell.isAppGrantedRoot() } returns null + Utils.isRootGranted() // Should not throw + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7db9256..987146c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,8 @@ compose = "1.7.3" shizuku = "13.1.5" hilt = "2.48" datastore = "1.1.1" +mockk = "1.13.10" +coroutinesTest = "1.8.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -40,6 +42,10 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.1.0" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }