From a11dee42a707b024033e16b802f014966aad0b89 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:20:00 -0500 Subject: [PATCH] test: migrate Compose UI tests from androidTest to commonTest (#5091) --- .github/workflows/codeql.yml | 108 ------------------ .github/workflows/main-check.yml | 2 - .github/workflows/merge-queue.yml | 2 - .github/workflows/pull-request.yml | 8 +- .github/workflows/reusable-check.yml | 96 +--------------- .skills/code-review/SKILL.md | 1 + .skills/testing-ci/SKILL.md | 7 +- app/build.gradle.kts | 6 - .../filter/MessageFilterIntegrationTest.kt | 48 -------- core/ble/build.gradle.kts | 7 -- core/ui/build.gradle.kts | 4 +- .../core/ui/component/AlertHostTest.kt | 34 ++++-- .../core/ui/component/ImportFabUiTest.kt | 56 +++++---- .../core/ui/util/AlertManagerUiTest.kt | 38 +++--- docs/decisions/architecture-review-2026-03.md | 18 +-- feature/firmware/build.gradle.kts | 8 -- feature/intro/build.gradle.kts | 7 -- feature/map/build.gradle.kts | 7 -- feature/messaging/build.gradle.kts | 5 +- .../messaging/component/MessageItemTest.kt | 34 +++--- feature/node/build.gradle.kts | 9 -- feature/settings/build.gradle.kts | 17 +-- .../component/MapReportingPreferenceTest.kt | 98 ---------------- .../settings/debugging/DebugSearchTest.kt | 76 ++++++------ .../component/EditDeviceProfileDialogTest.kt | 97 ++++++++-------- .../component/MapReportingPreferenceTest.kt | 99 ++++++++++++++++ gradle/libs.versions.toml | 1 + 27 files changed, 296 insertions(+), 597 deletions(-) delete mode 100644 .github/workflows/codeql.yml delete mode 100644 app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt (54%) rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt (63%) rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt (61%) rename feature/messaging/src/{androidTest => commonTest}/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt (81%) delete mode 100644 feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt rename feature/settings/src/{androidHostTest => commonTest}/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt (71%) rename feature/settings/src/{androidTest => commonTest}/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt (54%) create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index e67a217c7..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,108 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL Advanced" - -on: - # push: - # branches: [ "main" ] - # pull_request: - # branches: [ "main" ] - schedule: - - cron: '0 0 * * 0' - workflow_dispatch: - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-24.04' }} - if: github.repository == 'meshtastic/Meshtastic-Android' - permissions: - # required for all workflows - security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - include: - - language: actions - build-mode: none - - language: java-kotlin - build-mode: autobuild - # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 - - name: Java Setup - uses: actions/setup-java@v5 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' - token: ${{ github.token }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml index 4ef967dfc..eaf3f54d3 100644 --- a/.github/workflows/main-check.yml +++ b/.github/workflows/main-check.yml @@ -21,8 +21,6 @@ jobs: with: run_lint: true run_unit_tests: false - run_instrumented_tests: false run_desktop_builds: false - api_levels: '[35]' upload_artifacts: true secrets: inherit diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml index 2818ca939..44d31183d 100644 --- a/.github/workflows/merge-queue.yml +++ b/.github/workflows/merge-queue.yml @@ -18,8 +18,6 @@ jobs: with: run_lint: true run_unit_tests: true - run_instrumented_tests: true - api_levels: '[26, 35]' # Comprehensive testing for Merge Queue upload_artifacts: false secrets: inherit diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 7c2ea7f50..22a611576 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -99,9 +99,9 @@ jobs: PY # 2. VALIDATION & BUILD: Delegate to reusable-check.yml - # We disable instrumented tests, coverage, and desktop builds for PRs to keep - # feedback fast (< 10 mins). Desktop compilation is already covered by the - # :desktop:test task in the shard-app test shard. + # We disable coverage and desktop builds for PRs to keep feedback fast + # (< 10 mins). Desktop compilation is already covered by the :desktop:test + # task in the shard-app test shard. validate-and-build: needs: check-changes if: needs.check-changes.outputs.android == 'true' @@ -109,10 +109,8 @@ jobs: with: run_lint: true run_unit_tests: true - run_instrumented_tests: false run_coverage: false run_desktop_builds: false - api_levels: '[35]' upload_artifacts: true secrets: inherit diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 8e310e9ac..26dbe7685 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -9,15 +9,9 @@ on: run_unit_tests: type: boolean default: true - run_instrumented_tests: - type: boolean - default: true run_coverage: type: boolean default: true - api_levels: - type: string - default: '[35]' run_desktop_builds: type: boolean default: true @@ -238,7 +232,7 @@ jobs: **/build/test-results retention-days: 7 - # ── Android Build & Instrumented Tests ────────────────────────────── + # ── Android Build ──────────────────────────────────────────────────── android-check: runs-on: ubuntu-24.04 permissions: @@ -247,10 +241,6 @@ jobs: needs: lint-check env: VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} - strategy: - fail-fast: true - matrix: - api_level: ${{ fromJson(inputs.api_levels) }} steps: - name: Checkout code @@ -265,99 +255,25 @@ jobs: gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - - name: Determine matrix metadata - id: matrix_meta - shell: bash - run: | - first_api=$(python3 - <<'PY' - import json - print(json.loads('${{ inputs.api_levels }}')[0]) - PY - ) - - if [[ "${{ matrix.api_level }}" == "$first_api" ]]; then - echo "is_first_api=true" >> "$GITHUB_OUTPUT" - else - echo "is_first_api=false" >> "$GITHUB_OUTPUT" - fi - - - name: Determine Android tasks - id: tasks - shell: bash - run: | - tasks=( - "app:assembleFdroidDebug" - "app:assembleGoogleDebug" - ) - - if [[ "${{ inputs.run_instrumented_tests }}" == "true" ]]; then - tasks+=( - "app:connectedFdroidDebugAndroidTest" - "app:connectedGoogleDebugAndroidTest" - ) - fi - - printf 'tasks=%s\n' "${tasks[*]}" >> "$GITHUB_OUTPUT" - - - name: Enable KVM group perms - if: inputs.run_instrumented_tests == true - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Run Android Build & Instrumented Tests - if: inputs.run_instrumented_tests == true - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api_level }} - arch: x86_64 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan - - - name: Run Android Build - if: inputs.run_instrumented_tests == false - run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan - - - name: Upload instrumented test results to Codecov - if: ${{ !cancelled() && inputs.run_instrumented_tests && steps.matrix_meta.outputs.is_first_api == 'true' }} - uses: codecov/codecov-action@v6 - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: meshtastic/Meshtastic-Android - flags: android-instrumented - fail_ci_if_error: false - report_type: test_results - files: "**/build/outputs/androidTest-results/**/*.xml" + - name: Build Android APKs + run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan - name: Upload debug artifact - if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }} + if: ${{ inputs.upload_artifacts }} uses: actions/upload-artifact@v7 with: name: app-debug-apks path: app/build/outputs/apk/*/debug/*.apk - retention-days: 14 + retention-days: 7 - name: Report App Size - if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }} + if: always() run: | echo "### App Size Report" >> $GITHUB_STEP_SUMMARY echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY - - name: Upload Android reports - if: ${{ always() && inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 - with: - name: reports-android-api-${{ matrix.api_level }} - path: | - **/build/outputs/androidTest-results - retention-days: 7 - if-no-files-found: ignore - # ── Desktop Build ─────────────────────────────────────────────────── build-desktop: name: Build Desktop Debug (${{ matrix.os }}) diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md index 08caa95be..dce08761d 100644 --- a/.skills/code-review/SKILL.md +++ b/.skills/code-review/SKILL.md @@ -56,6 +56,7 @@ When reviewing code, meticulously verify the following categories. Flag any devi - [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules. ### 7. Testing +- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests. - [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`. - [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. - [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues. diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 8342714de..586c1ef9c 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -34,14 +34,13 @@ Run in this order for routine changes to ensure code formatting, analysis, and b - `worker/service/background` changes: Broad tests, targeted WorkManager checks. - `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`. -## 3) Flavor and instrumentation checks +## 3) Flavor checks Run these when relevant to map, provider, or flavor-specific behavior: ```bash ./gradlew lintFdroidDebug lintGoogleDebug ./gradlew testFdroidDebug testGoogleDebug -./gradlew connectedAndroidTest ``` ## 4) CI Pipeline Architecture @@ -55,12 +54,12 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`). Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. -3. **`android-check`** — Builds APKs and runs instrumented tests (depends on `lint-check`). +3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`). 4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`). ### Runner Strategy (Three Tiers) - **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times. -- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin for reproducibility. +- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, publish, dependency-submission). Pin for reproducibility. - **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging. ### CI Gradle Properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed9f3a766..1c8ed4c39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -297,12 +297,6 @@ dependencies { fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } fdroidImplementation(libs.osmbonuspack) - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.kotlinx.coroutines.test) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - androidTestImplementation(libs.koin.test) - testImplementation(kotlin("test-junit")) testImplementation(libs.androidx.work.testing) testImplementation(libs.koin.test) diff --git a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt deleted file mode 100644 index 4cbf88356..000000000 --- a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.filter - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.koin.test.KoinTest -import org.koin.test.inject -import org.meshtastic.core.repository.FilterPrefs -import org.meshtastic.core.repository.MessageFilter - -@RunWith(AndroidJUnit4::class) -class MessageFilterIntegrationTest : KoinTest { - - private val filterPrefs: FilterPrefs by inject() - - private val filterService: MessageFilter by inject() - - @org.junit.Ignore("Flaky integration test, needs Koin test rule setup") - @Test - fun filterPrefsIntegration() = runTest { - filterPrefs.setFilterEnabled(true) - filterPrefs.setFilterWords(setOf("test", "spam")) - // Wait briefly for DataStore to process the writes and flows to emit - kotlinx.coroutines.delay(100) - filterService.rebuildPatterns() - - assertTrue(filterService.shouldFilter("this is a test message")) - assertTrue(filterService.shouldFilter("spam content")) - } -} diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts index d26431634..f270e6aa3 100644 --- a/core/ble/build.gradle.kts +++ b/core/ble/build.gradle.kts @@ -50,12 +50,5 @@ kotlin { implementation(libs.kotlinx.coroutines.test) implementation(projects.core.testing) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.androidx.lifecycle.testing) - } - } } } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index bbe3204e5..76475e096 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -26,7 +26,6 @@ kotlin { android { namespace = "org.meshtastic.core.ui" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -70,8 +69,9 @@ kotlin { implementation(projects.core.testing) implementation(libs.junit) implementation(libs.kotlinx.coroutines.test) + implementation(libs.compose.multiplatform.ui.test) } - val androidHostTest by getting { dependencies { implementation(libs.androidx.test.runner) } } + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt similarity index 54% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt index b6abd64b0..ab0f1a80f 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt @@ -16,28 +16,46 @@ */ package org.meshtastic.core.ui.component +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText -import org.junit.Rule -import org.junit.Test +import androidx.compose.ui.test.runComposeUiTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain import org.meshtastic.core.ui.util.AlertManager +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) class AlertHostTest { - @get:Rule val composeTestRule = createComposeRule() + private val testDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } @Test - fun alertHost_showsDialog_whenAlertIsTriggered() { + fun alertHost_showsDialog_whenAlertIsTriggered() = runComposeUiTest { val alertManager = AlertManager() val title = "Alert Title" val message = "Alert Message" - composeTestRule.setContent { AlertHost(alertManager = alertManager) } + setContent { AlertHost(alertManager = alertManager) } alertManager.showAlert(title = title, message = message) - composeTestRule.onNodeWithText(title).assertIsDisplayed() - composeTestRule.onNodeWithText(message).assertIsDisplayed() + onNodeWithText(title).assertIsDisplayed() + onNodeWithText(message).assertIsDisplayed() } } diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt similarity index 63% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt index cc4f32b8e..650671de2 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt @@ -18,27 +18,25 @@ package org.meshtastic.core.ui.component import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.assertDoesNotExist +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test +import androidx.compose.ui.test.runComposeUiTest import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User +import kotlin.test.Test +@OptIn(ExperimentalTestApi::class) class ImportFabUiTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun importFab_expands_onButtonClick_whenSupported() { + fun importFab_expands_onButtonClick_whenSupported() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { CompositionLocalProvider( LocalBarcodeScannerSupported provides true, LocalNfcScannerSupported provides true, @@ -48,18 +46,18 @@ class ImportFabUiTest { } // Expand the FAB - composeTestRule.onNodeWithTag(testTag).performClick() + onNodeWithTag(testTag).performClick() // Verify menu items are visible using their tags - composeTestRule.onNodeWithTag("nfc_import").assertIsDisplayed() - composeTestRule.onNodeWithTag("qr_import").assertIsDisplayed() - composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() + onNodeWithTag("nfc_import").assertIsDisplayed() + onNodeWithTag("qr_import").assertIsDisplayed() + onNodeWithTag("url_import").assertIsDisplayed() } @Test - fun importFab_hidesNfcAndQr_whenNotSupported() { + fun importFab_hidesNfcAndQr_whenNotSupported() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { CompositionLocalProvider( LocalBarcodeScannerSupported provides false, LocalNfcScannerSupported provides false, @@ -69,41 +67,41 @@ class ImportFabUiTest { } // Expand the FAB - composeTestRule.onNodeWithTag(testTag).performClick() + onNodeWithTag(testTag).performClick() // Verify menu items are visible using their tags - composeTestRule.onNodeWithTag("nfc_import").assertDoesNotExist() - composeTestRule.onNodeWithTag("qr_import").assertDoesNotExist() - composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() + onNodeWithTag("nfc_import").assertDoesNotExist() + onNodeWithTag("qr_import").assertDoesNotExist() + onNodeWithTag("url_import").assertIsDisplayed() } @Test - fun importFab_showsUrlDialog_whenUrlItemClicked() { + fun importFab_showsUrlDialog_whenUrlItemClicked() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } + setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } - composeTestRule.onNodeWithTag(testTag).performClick() - composeTestRule.onNodeWithTag("url_import").performClick() + onNodeWithTag(testTag).performClick() + onNodeWithTag("url_import").performClick() // The URL dialog should be shown. // We'll search for its title indirectly or check if an AlertDialog appeared. } @Test - fun importFab_showsShareChannels_whenCallbackProvided() { + fun importFab_showsShareChannels_whenCallbackProvided() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { MeshtasticImportFAB(onImport = {}, onShareChannels = {}, isContactContext = false, testTag = testTag) } - composeTestRule.onNodeWithTag(testTag).performClick() - composeTestRule.onNodeWithTag("share_channels").assertIsDisplayed() + onNodeWithTag(testTag).performClick() + onNodeWithTag("share_channels").assertIsDisplayed() } @Test - fun importFab_showsSharedContactDialog_whenProvided() { + fun importFab_showsSharedContactDialog_whenProvided() = runComposeUiTest { val contact = SharedContact(user = User(long_name = "Suzume Goddess"), node_num = 1) - composeTestRule.setContent { + setContent { MeshtasticImportFAB( onImport = {}, sharedContact = contact, @@ -113,6 +111,6 @@ class ImportFabUiTest { } // Check if goddess is here - composeTestRule.onNodeWithText("Importing Suzume Goddess").assertIsDisplayed() + onNodeWithText("Importing Suzume Goddess").assertIsDisplayed() } } diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt similarity index 61% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt index 5632d39c1..7d2e1d1a4 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt @@ -18,22 +18,21 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertTrue +@OptIn(ExperimentalTestApi::class) class AlertManagerUiTest { - @get:Rule val composeTestRule = createComposeRule() - - private val alertManager = AlertManager() - @Test - fun alertManager_showsAlert_whenRequested() { - composeTestRule.setContent { + fun alertManager_showsAlert_whenRequested() = runComposeUiTest { + val alertManager = AlertManager() + setContent { val alertData by alertManager.currentAlert.collectAsState() alertData?.let { data -> AlertPreviewRenderer(data) } } @@ -43,29 +42,24 @@ class AlertManagerUiTest { alertManager.showAlert(title = title, message = message) - composeTestRule.onNodeWithText(title).assertIsDisplayed() - composeTestRule.onNodeWithText(message).assertIsDisplayed() + onNodeWithText(title).assertIsDisplayed() + onNodeWithText(message).assertIsDisplayed() } @Test - fun alertManager_confirmButton_triggersCallbackAndDismisses() { + fun alertManager_confirmButton_triggersCallbackAndDismisses() = runComposeUiTest { + val alertManager = AlertManager() var confirmClicked = false - composeTestRule.setContent { + setContent { val alertData by alertManager.currentAlert.collectAsState() alertData?.let { data -> AlertPreviewRenderer(data) } } - alertManager.showAlert(title = "Confirm Title", onConfirm = { confirmClicked = true }) - - // Default confirm text is "Okay" from resources, but AlertPreviewRenderer uses it - // We'll search for the text "Okay" (assuming it matches the resource value) - // Since we are in a test, we might need to use a hardcoded string or a resource - // But for this test, let's just use the confirmText parameter to be sure alertManager.showAlert(title = "Confirm Title", confirmText = "Yes", onConfirm = { confirmClicked = true }) - composeTestRule.onNodeWithText("Yes").performClick() + onNodeWithText("Yes").performClick() - assert(confirmClicked) - composeTestRule.onNodeWithText("Confirm Title").assertDoesNotExist() + assertTrue(confirmClicked) + onNodeWithText("Confirm Title").assertDoesNotExist() } } diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md index 68ed44809..4d225d58c 100644 --- a/docs/decisions/architecture-review-2026-03.md +++ b/docs/decisions/architecture-review-2026-03.md @@ -161,16 +161,16 @@ Android uses `@Module`-annotated classes (`CoreDataModule`, `CoreBleAndroidModul ### D1. Zero `commonTest` in feature modules *(resolved 2026-03-12)* -| Module | `commonTest` | `test`/`androidUnitTest` | `androidTest` | -|---|---:|---:|---:| -| `feature:settings` | 22 | 20 | 15 | -| `feature:node` | 24 | 9 | 0 | -| `feature:messaging` | 18 | 5 | 3 | -| `feature:connections` | 27 | 0 | 0 | -| `feature:firmware` | 15 | 25 | 0 | -| `feature:wifi-provision` | 62 | 0 | 0 | +| Module | `commonTest` | `test`/`androidUnitTest` | +|---|---:|---:| +| `feature:settings` | 33 | 20 | +| `feature:node` | 24 | 9 | +| `feature:messaging` | 21 | 5 | +| `feature:connections` | 27 | 0 | +| `feature:firmware` | 15 | 25 | +| `feature:wifi-provision` | 62 | 0 | -**Outcome:** All 8 feature modules now have `commonTest` coverage (193 shared tests). Combined with 70 platform unit tests and 18 instrumented tests, feature modules have 281 tests total. +**Outcome:** All 8 feature modules now have `commonTest` coverage (211 shared tests). Combined with 70 platform unit tests, feature modules have 281 tests total. All Compose UI tests have been migrated from `androidTest` to `commonTest` using CMP `runComposeUiTest`; instrumented test infrastructure has been removed from CI. ### D2. No shared test fixtures *(resolved 2026-03-12)* diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index c654e6e6f..a1b35c797 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -59,13 +59,5 @@ kotlin { androidMain.dependencies { implementation(libs.markdown.renderer.android) } commonTest.dependencies { implementation(projects.core.testing) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.test.ext.junit) - } - } } } diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts index 1dc180a42..5429361f5 100644 --- a/feature/intro/build.gradle.kts +++ b/feature/intro/build.gradle.kts @@ -38,12 +38,5 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - } - } } } diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index ebd5ec2c9..db52c350a 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -43,12 +43,5 @@ kotlin { implementation(projects.core.ui) implementation(projects.core.di) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - } - } } } diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index e06b417b7..80eed61c5 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -24,7 +24,6 @@ kotlin { android { namespace = "org.meshtastic.feature.messaging" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -56,6 +55,8 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) } - val androidHostTest by getting { dependencies { implementation(libs.androidx.work.testing) } } + commonTest.dependencies { implementation(libs.compose.multiplatform.ui.test) } + + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt similarity index 81% rename from feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt rename to feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 30f65afff..68f7817aa 100644 --- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -16,25 +16,21 @@ */ package org.meshtastic.feature.messaging.component +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.tooling.preview.NodePreviewParameterProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith +import androidx.compose.ui.test.runComposeUiTest import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) class MessageItemTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun mqttIconIsDisplayedWhenViaMqttIsTrue() { + fun mqttIconIsDisplayedWhenViaMqttIsTrue() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val messageWithMqtt = Message( @@ -56,7 +52,7 @@ class MessageItemTest { viaMqtt = true, ) - composeTestRule.setContent { + setContent { MessageItem( message = messageWithMqtt, node = testNode, @@ -69,11 +65,11 @@ class MessageItemTest { } // Check that the MQTT icon is displayed - composeTestRule.onNodeWithContentDescription("via MQTT").assertIsDisplayed() + onNodeWithContentDescription("via MQTT").assertIsDisplayed() } @Test - fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() { + fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val messageWithoutMqtt = Message( @@ -95,7 +91,7 @@ class MessageItemTest { viaMqtt = false, ) - composeTestRule.setContent { + setContent { MessageItem( message = messageWithoutMqtt, node = testNode, @@ -108,11 +104,11 @@ class MessageItemTest { } // Check that the MQTT icon is not displayed - composeTestRule.onNodeWithContentDescription("via MQTT").assertDoesNotExist() + onNodeWithContentDescription("via MQTT").assertDoesNotExist() } @Test - fun messageItem_hasCorrectSemanticContentDescription() { + fun messageItem_hasCorrectSemanticContentDescription() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val message = Message( @@ -134,7 +130,7 @@ class MessageItemTest { viaMqtt = false, ) - composeTestRule.setContent { + setContent { MessageItem( message = message, node = testNode, @@ -147,8 +143,6 @@ class MessageItemTest { } // Verify that the node containing the message text exists and matches the text - composeTestRule - .onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World") - .assertIsDisplayed() + onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World").assertIsDisplayed() } } diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 6195fb13b..0d89b55f6 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -62,14 +62,5 @@ kotlin { } androidMain.dependencies { implementation(libs.markdown.renderer.android) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.androidx.test.ext.junit) - } - } } } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 4b868fbc4..2793f3625 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -26,7 +26,6 @@ kotlin { android { namespace = "org.meshtastic.feature.settings" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -57,17 +56,11 @@ kotlin { implementation(libs.androidx.appcompat) } - commonTest.dependencies { implementation(project(":core:datastore")) } - - val androidHostTest by getting { - dependencies { - implementation(project(":core:datastore")) - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.androidx.compose.ui.test.manifest) - implementation(libs.androidx.test.ext.junit) - } + commonTest.dependencies { + implementation(project(":core:datastore")) + implementation(libs.compose.multiplatform.ui.test) } + + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt deleted file mode 100644 index 9eb31a6e7..000000000 --- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings.radio.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getString -import org.meshtastic.core.resources.i_agree -import org.meshtastic.core.resources.map_reporting -import org.meshtastic.core.resources.map_reporting_summary - -@RunWith(AndroidJUnit4::class) -class MapReportingPreferenceTest { - - @get:Rule val composeTestRule = createComposeRule() - - private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id) - - var mapReportingEnabled = false - var shouldReportLocation = false - var positionPrecision = 5 - var positionReportingInterval = 60 - - var mapReportingEnabledChanged = { enabled: Boolean -> mapReportingEnabled = enabled } - var shouldReportLocationChanged = { enabled: Boolean -> shouldReportLocation = enabled } - var positionPrecisionChanged = { precision: Int -> positionPrecision = precision } - var positionReportingIntervalChanged = { interval: Int -> positionReportingInterval = interval } - - private fun testMapReportingPreference() = composeTestRule.setContent { - Column { - MapReportingPreference( - mapReportingEnabled = mapReportingEnabled, - shouldReportLocation = shouldReportLocation, - positionPrecision = positionPrecision, - onMapReportingEnabledChanged = mapReportingEnabledChanged, - onShouldReportLocationChanged = shouldReportLocationChanged, - onPositionPrecisionChanged = positionPrecisionChanged, - publishIntervalSecs = positionReportingInterval, - onPublishIntervalSecsChanged = positionReportingIntervalChanged, - enabled = true, - ) - } - } - - @Test - fun testMapReportingPreference_showsText() { - composeTestRule.apply { - testMapReportingPreference() - // Verify that the dialog title is displayed - onNodeWithText(getString(Res.string.map_reporting)).assertIsDisplayed() - onNodeWithText(getString(Res.string.map_reporting_summary)).assertIsDisplayed() - } - } - - @Test - fun testMapReportingPreference_toggleMapReporting() { - composeTestRule.apply { - testMapReportingPreference() - onNodeWithText(getString(Res.string.i_agree)).assertIsNotDisplayed() - onNodeWithText(getString(Res.string.map_reporting)).performClick() - Assert.assertFalse(mapReportingEnabled) - Assert.assertFalse(shouldReportLocation) - onNodeWithText(getString(Res.string.i_agree)).assertIsDisplayed() - onNodeWithText(getString(Res.string.i_agree)).performClick() - Assert.assertTrue(shouldReportLocation) - Assert.assertTrue(mapReportingEnabled) - onNodeWithText(getString(Res.string.map_reporting)).performClick() - onNodeWithText(getString(Res.string.i_agree)).assertIsNotDisplayed() - Assert.assertTrue(shouldReportLocation) - Assert.assertFalse(mapReportingEnabled) - } - } -} diff --git a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt similarity index 71% rename from feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt index b768528e9..f68a79f23 100644 --- a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt @@ -23,17 +23,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.unit.dp -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_active_filters import org.meshtastic.core.resources.debug_default_search @@ -42,18 +39,15 @@ import org.meshtastic.core.resources.getString import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchMatch import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchState -import org.robolectric.annotation.Config +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) -@Config(sdk = [34]) +@OptIn(ExperimentalTestApi::class) class DebugSearchTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun debugSearchBar_showsPlaceholder() { + fun debugSearchBar_showsPlaceholder() = runComposeUiTest { val placeholder = getString(Res.string.debug_default_search) - composeTestRule.setContent { + setContent { DebugSearchBar( searchState = SearchState(), onSearchTextChange = {}, @@ -62,13 +56,13 @@ class DebugSearchTest { onClearSearch = {}, ) } - composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() + onNodeWithText(placeholder).assertIsDisplayed() } @Test - fun debugSearchBar_showsClearButtonWhenTextEntered() { + fun debugSearchBar_showsClearButtonWhenTextEntered() = runComposeUiTest { val placeholder = getString(Res.string.debug_default_search) - composeTestRule.setContent { + setContent { var searchText by remember { mutableStateOf("test") } DebugSearchBar( searchState = SearchState(searchText = searchText), @@ -78,17 +72,17 @@ class DebugSearchTest { onClearSearch = { searchText = "" }, ) } - composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick() - composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() + onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick() + onNodeWithText(placeholder).assertIsDisplayed() } @Test - fun debugSearchBar_searchFor_showsArrowsClearAndValues() { + fun debugSearchBar_searchFor_showsArrowsClearAndValues() = runComposeUiTest { val searchText = "test" val matchCount = 3 val currentMatchIndex = 1 - composeTestRule.setContent { + setContent { DebugSearchBar( searchState = SearchState( @@ -104,18 +98,18 @@ class DebugSearchTest { ) } // Check the match count display (e.g., '2/3') - composeTestRule.onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() + onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() // Check the navigation arrows - composeTestRule.onNodeWithContentDescription("Previous match").assertIsDisplayed() - composeTestRule.onNodeWithContentDescription("Next match").assertIsDisplayed() + onNodeWithContentDescription("Previous match").assertIsDisplayed() + onNodeWithContentDescription("Next match").assertIsDisplayed() // Check the clear button - composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed() + onNodeWithContentDescription("Clear search").assertIsDisplayed() } @Test - fun debugFilterBar_showsFilterButtonAndMenu() { + fun debugFilterBar_showsFilterButtonAndMenu() = runComposeUiTest { val filterLabel = getString(Res.string.debug_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf()) } var customFilterText by remember { mutableStateOf("") } val presetFilters = listOf("Error", "Warning", "Info") @@ -138,13 +132,13 @@ class DebugSearchTest { ) } // The filter button should be visible - composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed() + onNodeWithText(filterLabel).assertIsDisplayed() } @Test - fun debugFilterBar_addCustomFilter_displaysActiveFilter() { + fun debugFilterBar_addCustomFilter_displaysActiveFilter() = runComposeUiTest { val activeFiltersLabel = getString(Res.string.debug_active_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf()) } var customFilterText by remember { mutableStateOf("") } Column(modifier = Modifier.padding(16.dp)) { @@ -162,18 +156,16 @@ class DebugSearchTest { ) } } - with(composeTestRule) { - onNodeWithText("Add custom filter").performTextInput("MyFilter") - onNodeWithContentDescription("Add filter").performClick() - onNodeWithText(activeFiltersLabel).assertIsDisplayed() - onNodeWithText("MyFilter").assertIsDisplayed() - } + onNodeWithText("Add custom filter").performTextInput("MyFilter") + onNodeWithContentDescription("Add filter").performClick() + onNodeWithText(activeFiltersLabel).assertIsDisplayed() + onNodeWithText("MyFilter").assertIsDisplayed() } @Test - fun debugActiveFilters_clearAllFilters_removesFilters() { + fun debugActiveFilters_clearAllFilters_removesFilters() = runComposeUiTest { val activeFiltersLabel = getString(Res.string.debug_active_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf("A", "B")) } DebugActiveFilters( filterTexts = filterTexts, @@ -183,13 +175,13 @@ class DebugSearchTest { ) } // The active filters label and chips should be visible - composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() - composeTestRule.onNodeWithText("A").assertIsDisplayed() - composeTestRule.onNodeWithText("B").assertIsDisplayed() + onNodeWithText(activeFiltersLabel).assertIsDisplayed() + onNodeWithText("A").assertIsDisplayed() + onNodeWithText("B").assertIsDisplayed() // Click the clear all filters button - composeTestRule.onNodeWithContentDescription("Clear all filters").performClick() + onNodeWithContentDescription("Clear all filters").performClick() // The filter chips should no longer be visible - composeTestRule.onNodeWithText("A").assertDoesNotExist() - composeTestRule.onNodeWithText("B").assertDoesNotExist() + onNodeWithText("A").assertDoesNotExist() + onNodeWithText("B").assertDoesNotExist() } } diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt similarity index 54% rename from feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt index 1f390e44e..61d3b1219 100644 --- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt @@ -16,27 +16,24 @@ */ package org.meshtastic.feature.settings.radio.component +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith +import androidx.compose.ui.test.runComposeUiTest import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.save import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) class EditDeviceProfileDialogTest { - @get:Rule val composeTestRule = createComposeRule() - private val title = "Export configuration" private val deviceProfile = DeviceProfile( @@ -46,61 +43,61 @@ class EditDeviceProfileDialogTest { fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138), ) - private fun testEditDeviceProfileDialog(onDismiss: () -> Unit = {}, onConfirm: (DeviceProfile) -> Unit = {}) = - composeTestRule.setContent { + @Test + fun testEditDeviceProfileDialog_showsDialogTitle() = runComposeUiTest { + setContent { + EditDeviceProfileDialog(title = title, deviceProfile = deviceProfile, onConfirm = {}, onDismiss = {}) + } + + // Verify that the dialog title is displayed + onNodeWithText(title).assertIsDisplayed() + } + + @Test + fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() = runComposeUiTest { + setContent { + EditDeviceProfileDialog(title = title, deviceProfile = deviceProfile, onConfirm = {}, onDismiss = {}) + } + + // Verify the "Cancel" and "Save" buttons are displayed + onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed() + onNodeWithText(getString(Res.string.save)).assertIsDisplayed() + } + + @Test + fun testEditDeviceProfileDialog_clickCancelButton() = runComposeUiTest { + var onDismissClicked = false + setContent { EditDeviceProfileDialog( title = title, deviceProfile = deviceProfile, - onConfirm = onConfirm, - onDismiss = onDismiss, + onConfirm = {}, + onDismiss = { onDismissClicked = true }, ) } - @Test - fun testEditDeviceProfileDialog_showsDialogTitle() { - composeTestRule.apply { - testEditDeviceProfileDialog() - - // Verify that the dialog title is displayed - onNodeWithText(title).assertIsDisplayed() - } - } - - @Test - fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() { - composeTestRule.apply { - testEditDeviceProfileDialog() - - // Verify the "Cancel" and "Save" buttons are displayed - onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed() - onNodeWithText(getString(Res.string.save)).assertIsDisplayed() - } - } - - @Test - fun testEditDeviceProfileDialog_clickCancelButton() { - var onDismissClicked = false - composeTestRule.apply { - testEditDeviceProfileDialog(onDismiss = { onDismissClicked = true }) - - // Click the "Cancel" button - onNodeWithText(getString(Res.string.cancel)).performClick() - } + // Click the "Cancel" button + onNodeWithText(getString(Res.string.cancel)).performClick() // Verify onDismiss is called - Assert.assertTrue(onDismissClicked) + assertTrue(onDismissClicked) } @Test - fun testEditDeviceProfileDialog_addChannels() { + fun testEditDeviceProfileDialog_addChannels() = runComposeUiTest { var actualDeviceProfile: DeviceProfile? = null - composeTestRule.apply { - testEditDeviceProfileDialog(onConfirm = { actualDeviceProfile = it }) - - onNodeWithText(getString(Res.string.save)).performClick() + setContent { + EditDeviceProfileDialog( + title = title, + deviceProfile = deviceProfile, + onConfirm = { actualDeviceProfile = it }, + onDismiss = {}, + ) } + onNodeWithText(getString(Res.string.save)).performClick() + // Verify onConfirm is called with the correct DeviceProfile - Assert.assertEquals(deviceProfile, actualDeviceProfile) + assertEquals(deviceProfile, actualDeviceProfile) } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt new file mode 100644 index 000000000..850cc93e7 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.i_agree +import org.meshtastic.core.resources.map_reporting +import org.meshtastic.core.resources.map_reporting_summary +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class MapReportingPreferenceTest { + + var mapReportingEnabled = false + var shouldReportLocation = false + var positionPrecision = 5 + var positionReportingInterval = 60 + + var mapReportingEnabledChanged = { enabled: Boolean -> mapReportingEnabled = enabled } + var shouldReportLocationChanged = { enabled: Boolean -> shouldReportLocation = enabled } + var positionPrecisionChanged = { precision: Int -> positionPrecision = precision } + var positionReportingIntervalChanged = { interval: Int -> positionReportingInterval = interval } + + @Test + fun testMapReportingPreference_showsText() = runComposeUiTest { + setContent { + Column { + MapReportingPreference( + mapReportingEnabled = mapReportingEnabled, + shouldReportLocation = shouldReportLocation, + positionPrecision = positionPrecision, + onMapReportingEnabledChanged = mapReportingEnabledChanged, + onShouldReportLocationChanged = shouldReportLocationChanged, + onPositionPrecisionChanged = positionPrecisionChanged, + publishIntervalSecs = positionReportingInterval, + onPublishIntervalSecsChanged = positionReportingIntervalChanged, + enabled = true, + ) + } + } + // Verify that the dialog title is displayed + onNodeWithText(getString(Res.string.map_reporting)).assertIsDisplayed() + onNodeWithText(getString(Res.string.map_reporting_summary)).assertIsDisplayed() + } + + @Test + fun testMapReportingPreference_toggleMapReporting() = runComposeUiTest { + setContent { + Column { + MapReportingPreference( + mapReportingEnabled = mapReportingEnabled, + shouldReportLocation = shouldReportLocation, + positionPrecision = positionPrecision, + onMapReportingEnabledChanged = mapReportingEnabledChanged, + onShouldReportLocationChanged = shouldReportLocationChanged, + onPositionPrecisionChanged = positionPrecisionChanged, + publishIntervalSecs = positionReportingInterval, + onPublishIntervalSecsChanged = positionReportingIntervalChanged, + enabled = true, + ) + } + } + onNodeWithText(getString(Res.string.i_agree)).assertDoesNotExist() + onNodeWithText(getString(Res.string.map_reporting)).performClick() + assertFalse(mapReportingEnabled) + assertFalse(shouldReportLocation) + onNodeWithText(getString(Res.string.i_agree)).assertIsDisplayed() + onNodeWithText(getString(Res.string.i_agree)).performClick() + assertTrue(shouldReportLocation) + assertTrue(mapReportingEnabled) + onNodeWithText(getString(Res.string.map_reporting)).performClick() + onNodeWithText(getString(Res.string.i_agree)).assertDoesNotExist() + assertTrue(shouldReportLocation) + assertFalse(mapReportingEnabled) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1c5630ab..404b9f80e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -129,6 +129,7 @@ compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtim compose-multiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-multiplatform-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } compose-multiplatform-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } +compose-multiplatform-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "compose-multiplatform" } compose-multiplatform-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } compose-multiplatform-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" } compose-multiplatform-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-multiplatform-material3" }