diff --git a/.github/workflows/compose-ui-tests.yml b/.github/workflows/compose-ui-tests.yml new file mode 100644 index 00000000..eff33abd --- /dev/null +++ b/.github/workflows/compose-ui-tests.yml @@ -0,0 +1,61 @@ +name: Compose UI Tests + +# This workflow runs the Compose Multiplatform UI tests in commonTest +# These tests can run on multiple platforms and use the multiplatform UI testing framework + +on: + pull_request: + push: + branches: [ main, master ] + +# Cancel any current or previous job from the same PR +concurrency: + group: compose-ui-tests-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + compose-ui-tests: + name: Compose UI Tests (JVM) + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 17 + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' }} + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Compose UI Tests + run: ./gradlew :common:jvmTest --tests "*Ui*" --no-daemon --stacktrace + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: compose-ui-test-results + path: | + common/build/reports/tests/jvmTest/ + common/build/test-results/jvmTest/*.xml + retention-days: 7 + + - name: Publish Test Report + if: always() + uses: mikepenz/action-junit-report@v4 + with: + report_paths: 'common/build/test-results/jvmTest/*.xml' + check_name: 'Compose UI Test Results' + detailed_summary: true + include_passed: true + fail_on_failure: true diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cf484f5f..20f221ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive.layout) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index c91c68ac..ea63e0fd 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -79,6 +79,8 @@ kotlin { implementation(libs.koin.test) implementation(libs.kotlinx.coroutines.test) implementation(kotlin("test")) + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) } androidMain.dependencies { @@ -96,6 +98,10 @@ kotlin { implementation(libs.kotlinx.coroutines.swing) } + jvmTest.dependencies { + implementation(compose.desktop.currentOs) + } + appleMain.dependencies { implementation(libs.ktor.client.darwin) implementation(libs.sqldelight.native.driver) diff --git a/common/src/commonTest/kotlin/com/surrus/peopleinspace/PeopleInSpaceRepositoryFake.kt b/common/src/commonTest/kotlin/com/surrus/peopleinspace/PeopleInSpaceRepositoryFake.kt new file mode 100644 index 00000000..700c067a --- /dev/null +++ b/common/src/commonTest/kotlin/com/surrus/peopleinspace/PeopleInSpaceRepositoryFake.kt @@ -0,0 +1,29 @@ +package dev.johnoreilly.peopleinspace + +import dev.johnoreilly.common.remote.Assignment +import dev.johnoreilly.common.remote.IssPosition +import dev.johnoreilly.common.repository.PeopleInSpaceRepositoryInterface +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class PeopleInSpaceRepositoryFake: PeopleInSpaceRepositoryInterface { + val peopleList = listOf( + Assignment("ISS", "Chris Cassidy", "https://example.com/cassidy.jpg", "American astronaut", "USA"), + Assignment("ISS", "Anatoly Ivanishin", "https://example.com/ivanishin.jpg", "Russian cosmonaut", "Russia"), + Assignment("ISS", "Ivan Vagner", "https://example.com/vagner.jpg", "Russian cosmonaut", "Russia") + ) + + val issPosition = IssPosition(53.2743394, -9.0514163) + + override fun fetchPeopleAsFlow(): Flow> { + return flowOf(peopleList) + } + + override fun pollISSPosition(): Flow { + return flowOf(issPosition) + } + + override suspend fun fetchAndStorePeople() { + // No-op for fake + } +} diff --git a/common/src/commonTest/kotlin/com/surrus/peopleinspace/PeopleInSpaceTest.kt b/common/src/commonTest/kotlin/com/surrus/peopleinspace/PeopleInSpaceTest.kt deleted file mode 100644 index 66f44192..00000000 --- a/common/src/commonTest/kotlin/com/surrus/peopleinspace/PeopleInSpaceTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package dev.johnoreilly.peopleinspace - -import dev.johnoreilly.common.di.PeopleInSpaceDatabaseWrapper -import dev.johnoreilly.common.repository.PeopleInSpaceRepositoryInterface -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.koin.core.context.startKoin -import org.koin.dsl.module -import org.koin.test.KoinTest -import org.koin.test.inject -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertTrue - -//class PeopleInSpaceTest: KoinTest { -// private val repo : PeopleInSpaceRepositoryInterface by inject() -// -// @BeforeTest -// fun setUp() { -// Dispatchers.setMain(StandardTestDispatcher()) -// -// startKoin{ -// modules( -// commonModule(true), -// platformModule(), -// module { -// single { PeopleInSpaceDatabaseWrapper(null) } -// } -// ) -// } -// } -// -// @Test -// fun testGetPeople() = runTest { -// val result = repo.fetchPeople() -// println(result) -// assertTrue(result.isNotEmpty()) -// } -//} diff --git a/common/src/commonTest/kotlin/com/surrus/peopleinspace/ui/ComposeMultiplatformUiTests.kt b/common/src/commonTest/kotlin/com/surrus/peopleinspace/ui/ComposeMultiplatformUiTests.kt new file mode 100644 index 00000000..5087bd36 --- /dev/null +++ b/common/src/commonTest/kotlin/com/surrus/peopleinspace/ui/ComposeMultiplatformUiTests.kt @@ -0,0 +1,89 @@ +package dev.johnoreilly.peopleinspace.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.* +import dev.johnoreilly.common.ui.CoordinateDisplay +import kotlin.test.Test + +/** + * Compose Multiplatform UI Tests + * + * These tests use the multiplatform Compose UI testing framework and can run on + * Android, iOS, Desktop, and other platforms supported by Compose Multiplatform. + * + * Key differences from platform-specific tests: + * - Uses `runComposeUiTest` instead of `createComposeRule()` + * - Works with kotlin.test instead of JUnit + * - Can be executed on multiple platforms + */ +@OptIn(ExperimentalTestApi::class) +class ComposeMultiplatformUiTests { + + @Test + fun testCoordinateDisplay_showsLabelAndValue() = runComposeUiTest { + // Given + val label = "Latitude" + val value = "53.2743394" + + // When + setContent { + MaterialTheme { + CoordinateDisplay( + label = label, + value = value + ) + } + } + + // Then + onNodeWithText(label).assertIsDisplayed() + onNodeWithText(value).assertIsDisplayed() + } + + @Test + fun testCoordinateDisplay_longitudeDisplay() = runComposeUiTest { + // Given + val label = "Longitude" + val value = "-9.0514163" + + // When + setContent { + MaterialTheme { + CoordinateDisplay( + label = label, + value = value + ) + } + } + + // Then + onNodeWithText(label).assertExists() + onNodeWithText(value).assertExists() + } + + @Test + fun testCoordinateDisplay_withDifferentValues() = runComposeUiTest { + // Given + val testCases = listOf( + "Latitude" to "0.0", + "Longitude" to "180.0", + "Latitude" to "-90.0" + ) + + testCases.forEach { (label, value) -> + // When + setContent { + MaterialTheme { + CoordinateDisplay( + label = label, + value = value + ) + } + } + + // Then + onNodeWithText(label).assertIsDisplayed() + onNodeWithText(value).assertIsDisplayed() + } + } +} diff --git a/common/src/commonTest/kotlin/com/surrus/peopleinspace/ui/ISSPositionUiTests.kt b/common/src/commonTest/kotlin/com/surrus/peopleinspace/ui/ISSPositionUiTests.kt new file mode 100644 index 00000000..a1371dc0 --- /dev/null +++ b/common/src/commonTest/kotlin/com/surrus/peopleinspace/ui/ISSPositionUiTests.kt @@ -0,0 +1,120 @@ +package dev.johnoreilly.peopleinspace.ui + +import androidx.compose.ui.test.* +import dev.johnoreilly.common.remote.IssPosition +import dev.johnoreilly.common.ui.CoordinateDisplay +import dev.johnoreilly.peopleinspace.PeopleInSpaceRepositoryFake +import kotlin.test.Test + +/** + * UI Tests for ISS Position components + * + * These tests demonstrate testing Compose Multiplatform UI components + * with realistic data from a fake repository. + */ +@OptIn(ExperimentalTestApi::class) +class ISSPositionUiTests { + + private val repository = PeopleInSpaceRepositoryFake() + + @Test + fun testCoordinateDisplay_withISSPosition_displaysLatitude() = runComposeUiTest { + // Given + val position = repository.issPosition + + // When + setContent { + CoordinateDisplay( + label = "Latitude", + value = position.latitude.toString() + ) + } + + // Then + onNodeWithText("Latitude").assertIsDisplayed() + onNodeWithText(position.latitude.toString()).assertIsDisplayed() + } + + @Test + fun testCoordinateDisplay_withISSPosition_displaysLongitude() = runComposeUiTest { + // Given + val position = repository.issPosition + + // When + setContent { + CoordinateDisplay( + label = "Longitude", + value = position.longitude.toString() + ) + } + + // Then + onNodeWithText("Longitude").assertIsDisplayed() + onNodeWithText(position.longitude.toString()).assertIsDisplayed() + } + + @Test + fun testCoordinateDisplay_withZeroCoordinates() = runComposeUiTest { + // Given + val position = IssPosition(0.0, 0.0) + + // When + setContent { + CoordinateDisplay( + label = "Latitude", + value = position.latitude.toString() + ) + } + + // Then + onNodeWithText("0.0").assertExists() + } + + @Test + fun testCoordinateDisplay_withNegativeCoordinates() = runComposeUiTest { + // Given + val position = IssPosition(-45.5, -120.8) + + // When - Display Latitude + setContent { + CoordinateDisplay( + label = "Latitude", + value = position.latitude.toString() + ) + } + + // Then + onNodeWithText("-45.5").assertIsDisplayed() + } + + @Test + fun testCoordinateDisplay_withExtremeCoordinates() = runComposeUiTest { + // Given - Test North Pole + val northPole = IssPosition(90.0, 0.0) + + // When + setContent { + CoordinateDisplay( + label = "Latitude", + value = northPole.latitude.toString() + ) + } + + // Then + onNodeWithText("90.0").assertIsDisplayed() + + // Given - Test South Pole + val southPole = IssPosition(-90.0, 0.0) + + // When + setContent { + CoordinateDisplay( + label = "Latitude", + value = southPole.latitude.toString() + ) + } + + // Then + onNodeWithText("-90.0").assertIsDisplayed() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7c98360..98d87957 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,6 +67,7 @@ androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "material3-adaptive" } androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "material3-adaptive" } androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "material3-adaptive" } diff --git a/wearApp/src/main/java/com/surrus/peopleinspace/di/AppModule.kt b/wearApp/src/main/java/com/surrus/peopleinspace/di/AppModule.kt index f83af187..f80c689d 100644 --- a/wearApp/src/main/java/com/surrus/peopleinspace/di/AppModule.kt +++ b/wearApp/src/main/java/com/surrus/peopleinspace/di/AppModule.kt @@ -8,7 +8,7 @@ import dev.johnoreilly.peopleinspace.list.PersonListViewModel import dev.johnoreilly.peopleinspace.map.MapViewModel import dev.johnoreilly.peopleinspace.person.PersonDetailsViewModel import org.koin.android.ext.koin.androidContext -import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val wearAppModule = module {