diff --git a/.gitignore b/.gitignore index 3e9c9aae7..be4d533c0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,8 @@ replay_pid* # IDEA/Android Studio project files *.iml -.idea/* +.idea/ +**/.idea/ !.idea/copyright # Keep the code styles. !/.idea/codeStyles diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/AppNavigationTest.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/AppNavigationTest.kt new file mode 100644 index 000000000..09733adce --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/AppNavigationTest.kt @@ -0,0 +1,104 @@ +package edu.stanford.bdh.engagehf + +import android.Manifest +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.rule.GrantPermissionRule +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.bdh.engagehf.simulator.NavigatorSimulator +import edu.stanford.spezi.core.navigation.Navigator +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AppNavigationTest { + + @get:Rule(order = 1) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @get:Rule + val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + ) + + @Inject + lateinit var navigator: Navigator + + @Before + fun init() { + hiltRule.inject() + } + + @Test + fun `test start destination`() { + mainActivity { + assertOnboardingIsDisplayed() + } + } + + @Test + fun `test navigation to onboarding screen`() { + mainActivity { + navigateToOnboardingScreen() + assertOnboardingIsDisplayed() + } + } + + @Test + fun `test navigation to bluetooth screen`() { + mainActivity { + navigateToBluetoothScreen() + assertBluetoothScreenIsDisplayed() + } + } + + @Test + fun `test navigation to login screen`() { + mainActivity { + navigateToLoginScreen() + assertLoginScreenIsDisplayed() + } + } + + @Test + fun `test navigation to register screen`() { + mainActivity { + navigateToRegisterScreen() + assertRegisterScreenIsDisplayed() + } + } + + @Test + fun `test navigation to invitation code screen`() { + mainActivity { + navigateToInvitationCodeScreen() + assertInvitationCodeScreenIsDisplayed() + } + } + + @Test + fun `test navigation to sequential onboarding screen`() { + mainActivity { + navigateToSequentialOnboardingScreen() + assertSequentialOnboardingScreenIsDisplayed() + } + } + + @Test + fun `test navigation to consent screen`() { + mainActivity { + navigateToConsentScreen() + assertConsentScreenIsDisplayed() + } + } + + private fun mainActivity(scope: NavigatorSimulator.() -> Unit) { + NavigatorSimulator(composeTestRule, navigator).apply(scope) + } +} diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/OnboardingFlowTest.kt new file mode 100644 index 000000000..577fac607 --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/OnboardingFlowTest.kt @@ -0,0 +1,103 @@ +package edu.stanford.bdh.engagehf + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.bdh.engagehf.simulator.OnboardingFlowSimulator +import edu.stanford.spezi.module.onboarding.invitation.InvitationCodeRepository +import edu.stanford.spezi.module.onboarding.onboarding.OnboardingRepository +import edu.stanford.spezi.module.onboarding.sequential.SequentialOnboardingRepository +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class OnboardingFlowTest { + + @get:Rule(order = 1) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var onboardingRepository: OnboardingRepository + + @Inject + lateinit var sequentialOnboardingRepository: SequentialOnboardingRepository + + @Inject + lateinit var invitationCodeRepository: InvitationCodeRepository + + @Before + fun init() { + hiltRule.inject() + } + + @Test + fun `it should show expected state of initial onboarding screen`() = runTest { + val onboardingData = onboardingRepository.getOnboardingData().getOrThrow() + onboardingFlow { + onboardingScreen { + assertDisplayed() + assertTitle(text = onboardingData.title) + assertSubtitle(text = onboardingData.subTitle) + assertContinueButtonTitle(text = onboardingData.continueButtonText) + + onAreasList { + assertDisplayed() + onboardingData.areas.forEach { + assertAreaTitle(title = it.title) + } + } + } + } + } + + @Test + fun `it should navigate and display sequential onboarding correctly`() = runTest { + val stepTitle = sequentialOnboardingRepository.getSequentialOnboardingData().steps.first().title + onboardingFlow { + onboardingScreen { + clickContinueButton() + } + sequentialOnboarding { + assertIsDisplayed() + assertPagerIsDisplayed() + assertPageIndicatorIsDisplayed() + assertPageTitle(text = stepTitle) + } + } + } + + @Test + fun `it should display and navigate invitation screen correctly`() = runTest { + val steps = sequentialOnboardingRepository.getSequentialOnboardingData().steps + onboardingFlow { + onboardingScreen { + clickContinueButton() + } + sequentialOnboarding { + steps.forEach { + assertPageTitle(text = it.title) + clickForward() + } + } + + invitationCodeScreen { + val screenData = invitationCodeRepository.getScreenData() + assertTitle(text = screenData.title) + assertDescription(text = screenData.description) + assertIsDisplayed() + assertMainButtonDisplayed() + assertSecondaryButtonDisplayed() + } + } + } + + private fun onboardingFlow(scope: OnboardingFlowSimulator.() -> Unit) { + OnboardingFlowSimulator(composeTestRule).apply(scope) + } +} diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/InvitationCodeScreenSimulator.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/InvitationCodeScreenSimulator.kt new file mode 100644 index 000000000..e31960162 --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/InvitationCodeScreenSimulator.kt @@ -0,0 +1,48 @@ +package edu.stanford.bdh.engagehf.simulator + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeTestRule +import edu.stanford.spezi.core.testing.onNodeWithIdentifier +import edu.stanford.spezi.core.testing.waitNode +import edu.stanford.spezi.module.onboarding.invitation.InvitationCodeScreenTestIdentifier + +class InvitationCodeScreenSimulator( + private val composeTestRule: ComposeTestRule, +) { + private val root = composeTestRule.onNodeWithIdentifier(InvitationCodeScreenTestIdentifier.ROOT) + private val title = + composeTestRule.onNodeWithIdentifier(InvitationCodeScreenTestIdentifier.TITLE) + private val description = composeTestRule.onNodeWithIdentifier( + InvitationCodeScreenTestIdentifier.DESCRIPTION + ) + private val mainButton = + composeTestRule.onNodeWithIdentifier(InvitationCodeScreenTestIdentifier.MAIN_ACTION_BUTTON) + private val secondaryButton = + composeTestRule.onNodeWithIdentifier(InvitationCodeScreenTestIdentifier.SECONDARY_ACTION_BUTTON) + + fun assertIsDisplayed() { + composeTestRule.waitNode(InvitationCodeScreenTestIdentifier.ROOT) + root.assertIsDisplayed() + } + + fun assertTitle(text: String) { + title + .assertIsDisplayed() + .assertTextEquals(text) + } + + fun assertDescription(text: String) { + description + .assertIsDisplayed() + .assertTextEquals(text) + } + + fun assertMainButtonDisplayed() { + mainButton.assertIsDisplayed() + } + + fun assertSecondaryButtonDisplayed() { + secondaryButton.assertIsDisplayed() + } +} diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/NavigatorSimulator.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/NavigatorSimulator.kt new file mode 100644 index 000000000..902628162 --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/NavigatorSimulator.kt @@ -0,0 +1,103 @@ +package edu.stanford.bdh.engagehf.simulator + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import edu.stanford.bdh.engagehf.bluetooth.screen.BluetoothScreenTestIdentifier +import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent +import edu.stanford.spezi.core.navigation.Navigator +import edu.stanford.spezi.core.testing.onAllNodes +import edu.stanford.spezi.core.testing.onNodeWithIdentifier +import edu.stanford.spezi.core.utils.TestIdentifier +import edu.stanford.spezi.module.account.AccountNavigationEvent +import edu.stanford.spezi.module.account.login.LoginScreenTestIdentifier +import edu.stanford.spezi.module.account.register.RegisterScreenTestIdentifier +import edu.stanford.spezi.module.onboarding.OnboardingNavigationEvent +import edu.stanford.spezi.module.onboarding.consent.ConsentScreenTestIdentifier +import edu.stanford.spezi.module.onboarding.invitation.InvitationCodeScreenTestIdentifier +import edu.stanford.spezi.module.onboarding.onboarding.OnboardingScreenTestIdentifier +import edu.stanford.spezi.module.onboarding.sequential.SequentialOnboardingScreenTestIdentifier + +class NavigatorSimulator( + private val composeTestRule: ComposeTestRule, + private val navigator: Navigator, +) { + private val onboarding = + composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.ROOT) + private val register = composeTestRule.onNodeWithIdentifier(RegisterScreenTestIdentifier.ROOT) + private val login = composeTestRule.onNodeWithIdentifier(LoginScreenTestIdentifier.ROOT) + private val invitation = + composeTestRule.onNodeWithIdentifier(InvitationCodeScreenTestIdentifier.ROOT) + private val sequential = + composeTestRule.onNodeWithIdentifier(SequentialOnboardingScreenTestIdentifier.ROOT) + private val bluetooth = composeTestRule.onNodeWithIdentifier(BluetoothScreenTestIdentifier.ROOT) + private val consent = composeTestRule.onNodeWithIdentifier(ConsentScreenTestIdentifier.ROOT) + + fun assertOnboardingIsDisplayed() { + waitNode(OnboardingScreenTestIdentifier.ROOT) + onboarding.assertIsDisplayed() + } + + fun assertBluetoothScreenIsDisplayed() { + waitNode(BluetoothScreenTestIdentifier.ROOT) + bluetooth.assertIsDisplayed() + } + + fun assertLoginScreenIsDisplayed() { + waitNode(LoginScreenTestIdentifier.ROOT) + login.assertIsDisplayed() + } + + fun assertRegisterScreenIsDisplayed() { + waitNode(RegisterScreenTestIdentifier.ROOT) + register.assertIsDisplayed() + } + + fun assertInvitationCodeScreenIsDisplayed() { + waitNode(InvitationCodeScreenTestIdentifier.ROOT) + invitation.assertIsDisplayed() + } + + fun assertSequentialOnboardingScreenIsDisplayed() { + waitNode(SequentialOnboardingScreenTestIdentifier.ROOT) + sequential.assertIsDisplayed() + } + + fun assertConsentScreenIsDisplayed() { + waitNode(ConsentScreenTestIdentifier.ROOT) + consent.assertIsDisplayed() + } + + fun navigateToBluetoothScreen() { + navigator.navigateTo(AppNavigationEvent.BluetoothScreen) + } + + fun navigateToOnboardingScreen() { + navigator.navigateTo(OnboardingNavigationEvent.OnboardingScreen) + } + + fun navigateToLoginScreen() { + navigator.navigateTo(AccountNavigationEvent.LoginScreen(false)) + } + + fun navigateToRegisterScreen() { + navigator.navigateTo(AccountNavigationEvent.RegisterScreen()) + } + + fun navigateToInvitationCodeScreen() { + navigator.navigateTo(OnboardingNavigationEvent.InvitationCodeScreen) + } + + fun navigateToSequentialOnboardingScreen() { + navigator.navigateTo(OnboardingNavigationEvent.SequentialOnboardingScreen) + } + + fun navigateToConsentScreen() { + navigator.navigateTo(OnboardingNavigationEvent.ConsentScreen) + } + + private fun waitNode(testIdentifier: TestIdentifier) { + composeTestRule.waitUntil { + composeTestRule.onAllNodes(testIdentifier).fetchSemanticsNodes().isNotEmpty() + } + } +} diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/OnboardingFlowSimulator.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/OnboardingFlowSimulator.kt new file mode 100644 index 000000000..0ad626797 --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/OnboardingFlowSimulator.kt @@ -0,0 +1,23 @@ +package edu.stanford.bdh.engagehf.simulator + +import androidx.compose.ui.test.junit4.ComposeTestRule + +class OnboardingFlowSimulator( + composeTestRule: ComposeTestRule, +) { + private val onboardingScreenSimulator = OnboardingScreenSimulator(composeTestRule) + private val sequentialOnboardingScreenSimulator = SequentialOnboardingScreenSimulator(composeTestRule) + private val invitationCodeScreenSimulator = InvitationCodeScreenSimulator(composeTestRule) + + fun onboardingScreen(scope: OnboardingScreenSimulator.() -> Unit) { + onboardingScreenSimulator.apply(scope) + } + + fun sequentialOnboarding(scope: SequentialOnboardingScreenSimulator.() -> Unit) { + sequentialOnboardingScreenSimulator.apply(scope) + } + + fun invitationCodeScreen(scope: InvitationCodeScreenSimulator.() -> Unit) { + invitationCodeScreenSimulator.apply(scope) + } +} diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/OnboardingScreenSimulator.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/OnboardingScreenSimulator.kt new file mode 100644 index 000000000..1bf9cc5d2 --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/OnboardingScreenSimulator.kt @@ -0,0 +1,71 @@ +package edu.stanford.bdh.engagehf.simulator + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.performClick +import edu.stanford.spezi.core.testing.onNodeWithIdentifier +import edu.stanford.spezi.module.onboarding.onboarding.OnboardingScreenTestIdentifier + +class OnboardingScreenSimulator( + private val composeTestRule: ComposeTestRule, +) { + private val root = composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.ROOT) + private val title = composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.TITLE) + private val subtitle = + composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.SUBTITLE) + private val areasList = + composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.AREAS_LIST) + private val button = composeTestRule.onNodeWithIdentifier( + identifier = OnboardingScreenTestIdentifier.LEARN_MORE_BUTTON, + useUnmergedTree = true, + ) + + fun assertDisplayed() { + root.assertIsDisplayed() + } + + fun assertTitle(text: String) { + title + .assertIsDisplayed() + .assertTextEquals(text) + } + + fun assertSubtitle(text: String) { + subtitle + .assertIsDisplayed() + .assertTextEquals(text) + } + + fun assertContinueButtonTitle(text: String) { + button + .onChild() + .assert(hasText(text)) + } + + fun onAreasList(scope: AreasSimulator.() -> Unit) { + AreasSimulator().apply(scope) + } + + fun clickContinueButton() { + button + .assertHasClickAction() + .performClick() + } + + inner class AreasSimulator { + fun assertDisplayed() { + areasList.assertIsDisplayed() + } + + fun assertAreaTitle(title: String) { + composeTestRule + .onNodeWithIdentifier(OnboardingScreenTestIdentifier.AREA_TITLE, title) + .assertIsDisplayed() + } + } +} diff --git a/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/SequentialOnboardingScreenSimulator.kt b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/SequentialOnboardingScreenSimulator.kt new file mode 100644 index 000000000..33fe5e834 --- /dev/null +++ b/app/src/androidTest/kotlin/edu/stanford/bdh/engagehf/simulator/SequentialOnboardingScreenSimulator.kt @@ -0,0 +1,44 @@ +package edu.stanford.bdh.engagehf.simulator + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import edu.stanford.spezi.core.testing.onNodeWithIdentifier +import edu.stanford.spezi.core.testing.waitNode +import edu.stanford.spezi.module.onboarding.sequential.SequentialOnboardingScreenTestIdentifier +import edu.stanford.spezi.module.onboarding.sequential.components.PageIndicatorTestIdentifier + +class SequentialOnboardingScreenSimulator( + private val composeTestRule: ComposeTestRule, +) { + private val root = + composeTestRule.onNodeWithIdentifier(SequentialOnboardingScreenTestIdentifier.ROOT) + private val pager = + composeTestRule.onNodeWithIdentifier(SequentialOnboardingScreenTestIdentifier.PAGER) + private val pageIndicator = + composeTestRule.onNodeWithIdentifier(SequentialOnboardingScreenTestIdentifier.PAGE_INDICATOR) + private val forwardButton = composeTestRule.onNodeWithIdentifier(PageIndicatorTestIdentifier.FORWARD) + + fun assertIsDisplayed() { + composeTestRule.waitNode(SequentialOnboardingScreenTestIdentifier.ROOT) + root.assertIsDisplayed() + } + + fun assertPagerIsDisplayed() { + pager.assertIsDisplayed() + } + + fun assertPageIndicatorIsDisplayed() { + pageIndicator.assertIsDisplayed() + } + + fun assertPageTitle(text: String) { + composeTestRule + .onNodeWithIdentifier(SequentialOnboardingScreenTestIdentifier.PAGE, text) + .assertIsDisplayed() + } + + fun clickForward() { + forwardButton.performClick() + } +} diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/BluetoothScreen.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/BluetoothScreen.kt index 713492dae..9cb492ac4 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/BluetoothScreen.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/BluetoothScreen.kt @@ -25,6 +25,7 @@ import edu.stanford.bdh.engagehf.bluetooth.data.models.DeviceUiModel import edu.stanford.spezi.core.design.theme.Colors import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.design.theme.TextStyles +import edu.stanford.spezi.core.utils.extensions.testIdentifier import kotlinx.coroutines.flow.Flow @Composable @@ -39,6 +40,7 @@ fun BluetoothScreen() { private fun BluetoothScreen(uiState: BluetoothUiState) { Column( modifier = Modifier + .testIdentifier(BluetoothScreenTestIdentifier.ROOT) .fillMaxSize() .padding(Spacings.medium) ) { @@ -102,3 +104,7 @@ private fun BluetoothEvents(events: Flow) { } } } + +enum class BluetoothScreenTestIdentifier { + ROOT, +} diff --git a/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/extensions/Project.kt b/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/extensions/Project.kt index e527396bd..19bcc3162 100644 --- a/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/extensions/Project.kt +++ b/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/extensions/Project.kt @@ -28,7 +28,7 @@ inline fun Project.extension(configBlock: T.() -> Unit) { extensions.getByType().apply(configBlock) } -internal fun Project.commonExtensions(configBlock: CommonExtension<*,*,*,*,*,*>.() -> Unit) { +internal fun Project.android(configBlock: CommonExtension<*, *, *, *, *, *>.() -> Unit) { when { isApp() -> extension(configBlock) isLibrary() -> extension(configBlock) diff --git a/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziAbstractConfigPlugin.kt b/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziAbstractConfigPlugin.kt index a75413ca6..8c728f6e1 100644 --- a/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziAbstractConfigPlugin.kt +++ b/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziAbstractConfigPlugin.kt @@ -1,5 +1,7 @@ package edu.stanford.spezi.build.logic.convention.plugins +import edu.stanford.spezi.build.logic.convention.extensions.android +import edu.stanford.spezi.build.logic.convention.extensions.androidTestImplementation import edu.stanford.spezi.build.logic.convention.extensions.apply import edu.stanford.spezi.build.logic.convention.extensions.implementation import edu.stanford.spezi.build.logic.convention.extensions.testImplementation @@ -18,9 +20,19 @@ abstract class SpeziAbstractConfigPlugin(private val modulePlugin: PluginId) : P defaultConfig.apply(this) + android { + defaultConfig { + testInstrumentationRunner = "edu.stanford.spezi.core.testing.HiltApplicationTestRunner" + } + } + dependencies { + implementation(project(":core:utils")) implementation(project(":core:logging")) + testImplementation(project(":core:testing")) + + androidTestImplementation(project(":core:testing")) } } } diff --git a/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziBaseConfigConventionPlugin.kt b/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziBaseConfigConventionPlugin.kt index 11ca42dcb..3a8366c46 100644 --- a/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziBaseConfigConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziBaseConfigConventionPlugin.kt @@ -1,14 +1,13 @@ package edu.stanford.spezi.build.logic.convention.plugins import com.android.build.api.variant.LibraryAndroidComponentsExtension -import edu.stanford.spezi.build.logic.convention.extensions.commonExtensions +import edu.stanford.spezi.build.logic.convention.extensions.android import edu.stanford.spezi.build.logic.convention.extensions.findVersion import edu.stanford.spezi.build.logic.convention.extensions.isLibrary import org.gradle.api.JavaVersion import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask @@ -18,7 +17,7 @@ class SpeziBaseConfigConventionPlugin : Plugin { private val java = JavaVersion.VERSION_17 override fun apply(target: Project) = with(target) { - commonExtensions { + android { compileSdk = findVersion("compileSdk").toInt() defaultConfig { diff --git a/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziComposeConventionPlugin.kt index 976f83fd5..7f9aeceec 100644 --- a/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/edu/stanford/spezi/build/logic/convention/plugins/SpeziComposeConventionPlugin.kt @@ -2,7 +2,7 @@ package edu.stanford.spezi.build.logic.convention.plugins import edu.stanford.spezi.build.logic.convention.extensions.androidTestImplementation import edu.stanford.spezi.build.logic.convention.extensions.apply -import edu.stanford.spezi.build.logic.convention.extensions.commonExtensions +import edu.stanford.spezi.build.logic.convention.extensions.android import edu.stanford.spezi.build.logic.convention.extensions.debugImplementation import edu.stanford.spezi.build.logic.convention.extensions.findBundle import edu.stanford.spezi.build.logic.convention.extensions.findLibrary @@ -16,7 +16,7 @@ class SpeziComposeConventionPlugin : Plugin { override fun apply(project: Project) = with(project) { apply(PluginId.COMPOSE_COMPILER) - commonExtensions { + android { buildFeatures { compose = true } diff --git a/core/design/src/main/AndroidManifest.xml b/core/design/src/main/AndroidManifest.xml index 44008a433..dc8cf2078 100644 --- a/core/design/src/main/AndroidManifest.xml +++ b/core/design/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ - - - \ No newline at end of file + + + + + diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ComposeContentActivity.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ComposeContentActivity.kt new file mode 100644 index 000000000..4998ae64f --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ComposeContentActivity.kt @@ -0,0 +1,35 @@ +package edu.stanford.spezi.core.design.component + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.CallSuper +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import dagger.hilt.android.AndroidEntryPoint +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.utils.ComposableBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +@AndroidEntryPoint +class ComposeContentActivity : ComponentActivity() { + + private val content = MutableStateFlow(null) + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + SpeziTheme { + val content by content.collectAsState() + content?.invoke() + } + } + } + + fun setScreen(content: ComposableBlock) { + this.content.update { content } + } +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index c5256b030..aebc29388 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.spezi.base) + alias(libs.plugins.spezi.hilt) } android { @@ -10,6 +11,11 @@ android { dependencies { implementation(project(":core:coroutines")) + implementation(project(":core:utils")) + + implementation(libs.hilt.test) + implementation(libs.androidx.test.runner) api(libs.bundles.unit.testing) + api(libs.bundles.compose.androidTest) } diff --git a/core/testing/src/main/kotlin/edu/stanford/spezi/core/testing/HiltApplicationTestRunner.kt b/core/testing/src/main/kotlin/edu/stanford/spezi/core/testing/HiltApplicationTestRunner.kt new file mode 100644 index 000000000..c6ebc2c99 --- /dev/null +++ b/core/testing/src/main/kotlin/edu/stanford/spezi/core/testing/HiltApplicationTestRunner.kt @@ -0,0 +1,19 @@ +package edu.stanford.spezi.core.testing + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +/** + * A custom runner used to set up a hilt instrumented test application. + * + * Do not delete!!! It is referenced via fully qualified name in `SpeziAbstractConfigPlugin` + * which is the base convention plugin used by all the modules of the project + */ +@Suppress("Unused") +class HiltApplicationTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/core/testing/src/main/kotlin/edu/stanford/spezi/core/testing/TestIdentifier.kt b/core/testing/src/main/kotlin/edu/stanford/spezi/core/testing/TestIdentifier.kt new file mode 100644 index 000000000..cf9c2f566 --- /dev/null +++ b/core/testing/src/main/kotlin/edu/stanford/spezi/core/testing/TestIdentifier.kt @@ -0,0 +1,25 @@ +package edu.stanford.spezi.core.testing + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import edu.stanford.spezi.core.utils.TestIdentifier +import edu.stanford.spezi.core.utils.extensions.tag + +/** + * Finds a semantics node identified by the given test identifier. + */ +fun ComposeTestRule.onNodeWithIdentifier( + identifier: TestIdentifier, + suffix: String? = null, + useUnmergedTree: Boolean = false, +) = onNodeWithTag(identifier.tag(suffix = suffix), useUnmergedTree = useUnmergedTree) + +fun ComposeTestRule.onAllNodes( + identifier: TestIdentifier, + useUnmergedTree: Boolean = false, +) = onAllNodesWithTag(identifier.tag(), useUnmergedTree) + +fun ComposeTestRule.waitNode( + identifier: TestIdentifier, +) = onAllNodesWithTag(identifier.tag()).fetchSemanticsNodes().isNotEmpty() diff --git a/core/utils/build.gradle.kts b/core/utils/build.gradle.kts index 1dc328b15..aa7acbb2c 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -1,5 +1,7 @@ plugins { - alias(libs.plugins.spezi.library) + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.spezi.base) alias(libs.plugins.spezi.hilt) } @@ -10,5 +12,5 @@ android { dependencies { val composeBom = platform(libs.compose.bom) implementation(composeBom) - implementation(libs.compose.runtime) + implementation(libs.bundles.compose) } diff --git a/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/Typealiases.kt b/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/Typealiases.kt index 895b4b501..ee706ab7f 100644 --- a/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/Typealiases.kt +++ b/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/Typealiases.kt @@ -2,6 +2,7 @@ package edu.stanford.spezi.core.utils import androidx.compose.runtime.Composable +import edu.stanford.spezi.core.utils.extensions.tag /** * A type alias for a composable lambda function with no parameters and no return value. @@ -24,3 +25,9 @@ import androidx.compose.runtime.Composable * ``` */ typealias ComposableBlock = @Composable () -> Unit + +/** + * A type alias on any enum type. Useful to set test tag on composable of a Screen to + * ensure uniqueness of the tags, see [tag] + */ +typealias TestIdentifier = Enum<*> diff --git a/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/extensions/Modifier.kt b/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/extensions/Modifier.kt new file mode 100644 index 000000000..a06315aa8 --- /dev/null +++ b/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/extensions/Modifier.kt @@ -0,0 +1,8 @@ +package edu.stanford.spezi.core.utils.extensions + +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import edu.stanford.spezi.core.utils.TestIdentifier + +fun Modifier.testIdentifier(identifier: TestIdentifier, suffix: String? = null) = + testTag(tag = identifier.tag(suffix = suffix)) diff --git a/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/extensions/TestIdentifier.kt b/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/extensions/TestIdentifier.kt new file mode 100644 index 000000000..fce78f293 --- /dev/null +++ b/core/utils/src/main/kotlin/edu/stanford/spezi/core/utils/extensions/TestIdentifier.kt @@ -0,0 +1,8 @@ +package edu.stanford.spezi.core.utils.extensions + +import edu.stanford.spezi.core.utils.TestIdentifier + +/** + * Constructs the test tag of an enum as `EnumTypeName:EnumCaseName` + */ +fun TestIdentifier.tag(suffix: String? = null): String = "${javaClass.simpleName}:$name${suffix ?: ""}" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20365a2d8..59c7d5009 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ accompanistPager = "0.35.1-alpha" activityCompose = "1.9.0" agp = "8.4.1" -androidTools = "31.4.1" +androidTools = "31.4.2" appcompat = "1.7.0" compileSdk = "34" composeBom = "2024.05.00" @@ -11,8 +11,8 @@ composeNavigation = "2.8.0-alpha08" coreKtx = "1.13.1" coreTestingVersion = "2.2.0" coroutinesVersion = "1.8.0" -detekt = "1.23.6" # please adjust github action version as well in case of version change credentialsPlayServicesAuth = "1.2.2" +detekt = "1.23.6" # please adjust github action version as well in case of version change dokka = "1.9.20" espressoCore = "3.5.1" firebaseAuthKtx = "23.0.0" @@ -29,7 +29,6 @@ hiltVersion = "2.51" junit = "4.13.2" junitVersion = "1.1.5" kotlin = "2.0.0" -kotlinxSerialization = "1.9.22" kotlinxSerializationJson = "1.6.3" kspVersion = "2.0.0-1.0.21" lifecycleKtx = "2.8.1" @@ -37,7 +36,10 @@ lifecycleRuntimeKtx = "2.7.0" minSdk = "31" mockKVersion = "1.13.10" playServicesAuth = "21.2.0" +rulesVersion = "1.5.0" +runnerVersion = "1.5.2" targetSdk = "34" +testCoreVersion = "1.5.0" timberVersion = "5.0.1" truth = "1.4.2" @@ -57,6 +59,9 @@ androidx-foundation = { module = "androidx.compose.foundation:foundation", versi androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleKtx" } androidx-lifecycle-view-model-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCoreVersion" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "runnerVersion" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "rulesVersion" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } @@ -105,8 +110,8 @@ google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "kspVersio google-gms-google-services = { id = "com.google.gms.google-services", version.ref = "googleGmsGoogleServices" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerialization" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } spezi-application = { id = "spezi.application", version = "unspecified" } spezi-base = { id = "spezi.base", version = "unspecified" } spezi-compose = { id = "spezi.compose", version = "unspecified" } @@ -118,4 +123,4 @@ spezi-library = { id = "spezi.library", version = "unspecified" } compose = ["compose-ui", "compose-material3", "compose-ui-tooling-preview", "compose-ui-tooling", "androidx-core-ktx", "androidx-appcompat", "androidx-activity-compose"] compose-androidTest = ["junit", "androidx-espresso-core", "compose-ui-test", "mockk-agent-core", "mockk-android", "google-truth"] ktx-coroutines = ["coroutines-core", "coroutines-android"] -unit-testing = ["junit", "mockk-core", "google-truth", "coroutines-test", "androidx-core-testing"] +unit-testing = ["junit", "mockk-core", "google-truth", "coroutines-test", "androidx-core-testing", "androidx-test-rules"] diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginScreen.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginScreen.kt index 11f9c1cba..7df2ec673 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginScreen.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginScreen.kt @@ -32,6 +32,7 @@ import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.design.theme.SpeziTheme import edu.stanford.spezi.core.design.theme.TextStyles.bodyLarge import edu.stanford.spezi.core.design.theme.TextStyles.titleLarge +import edu.stanford.spezi.core.utils.extensions.testIdentifier import edu.stanford.spezi.module.account.login.components.SignInWithGoogleButton import edu.stanford.spezi.module.account.login.components.TextDivider @@ -55,6 +56,7 @@ internal fun LoginScreen( ) { Column( modifier = Modifier + .testIdentifier(LoginScreenTestIdentifier.ROOT) .fillMaxSize() .padding(Spacings.medium), horizontalAlignment = Alignment.CenterHorizontally, @@ -178,3 +180,7 @@ private class LoginScreenPreviewProvider : PreviewParameterProvider { ) ) } + +enum class LoginScreenTestIdentifier { + ROOT, +} diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterScreen.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterScreen.kt index 03d5b0100..af7f7c061 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterScreen.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterScreen.kt @@ -48,6 +48,7 @@ import edu.stanford.spezi.core.design.theme.SpeziTheme import edu.stanford.spezi.core.design.theme.TextStyles.labelLarge import edu.stanford.spezi.core.design.theme.TextStyles.titleLarge import edu.stanford.spezi.core.design.theme.TextStyles.titleSmall +import edu.stanford.spezi.core.utils.extensions.testIdentifier import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -79,6 +80,7 @@ fun RegisterScreen( ) { Column( modifier = Modifier + .testIdentifier(RegisterScreenTestIdentifier.ROOT) .fillMaxSize() .padding(Spacings.medium) .imePadding() @@ -268,3 +270,7 @@ private class RegisterScreenProvider : PreviewParameterProvider ), ) } + +enum class RegisterScreenTestIdentifier { + ROOT, +} diff --git a/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/OnboardingScreenTest.kt b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/OnboardingScreenTest.kt new file mode 100644 index 000000000..24f990342 --- /dev/null +++ b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/OnboardingScreenTest.kt @@ -0,0 +1,70 @@ +package edu.stanford.spezi.module.onboarding + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.spezi.core.design.component.ComposeContentActivity +import edu.stanford.spezi.module.onboarding.fakes.FakeOnboardingRepository +import edu.stanford.spezi.module.onboarding.onboarding.OnboardingScreen +import edu.stanford.spezi.module.onboarding.simulator.OnboardingScreenSimulator +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class OnboardingScreenTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var fakeOnboardingRepository: FakeOnboardingRepository + + @Before + fun setup() { + hiltRule.inject() + composeTestRule.activity.setScreen { OnboardingScreen() } + } + + @Test + fun `it should display the onboarding data correctly`() = runTest { + val onboardingData = fakeOnboardingRepository.getOnboardingData().getOrThrow() + onboardingScreen { + assertDisplayed() + assertTitle(text = onboardingData.title) + + assertSubtitle(text = onboardingData.subTitle) + + onAreasList { + assertDisplayed() + onboardingData.areas.forEach { + assertAreaTitle(title = it.title) + } + } + + assertContinueButtonTitle(text = onboardingData.continueButtonText) + } + } + + @Test + fun `it should handle click action correctly`() = runTest { + val clickAction: () -> Unit = mockk(relaxed = true) + fakeOnboardingRepository.setOnContinueAction(clickAction) + + onboardingScreen { + clickContinueButton() + } + + verify { clickAction.invoke() } + } + + private fun onboardingScreen(block: OnboardingScreenSimulator.() -> Unit) { + OnboardingScreenSimulator(composeTestRule).apply(block) + } +} diff --git a/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/di/TestOnboardingModule.kt b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/di/TestOnboardingModule.kt new file mode 100644 index 000000000..bc7454e37 --- /dev/null +++ b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/di/TestOnboardingModule.kt @@ -0,0 +1,49 @@ +package edu.stanford.spezi.module.onboarding.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import edu.stanford.spezi.module.onboarding.consent.ConsentManager +import edu.stanford.spezi.module.onboarding.consent.PdfService +import edu.stanford.spezi.module.onboarding.fakes.FakeOnboardingRepository +import edu.stanford.spezi.module.onboarding.invitation.InvitationAuthManager +import edu.stanford.spezi.module.onboarding.invitation.InvitationCodeRepository +import edu.stanford.spezi.module.onboarding.onboarding.OnboardingRepository +import edu.stanford.spezi.module.onboarding.sequential.SequentialOnboardingRepository +import io.mockk.mockk +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [OnboardingModule::class] +) +class TestOnboardingModule { + + @Provides + @Singleton + fun provideInvitationAuthManager(): InvitationAuthManager = mockk() + + @Provides + @Singleton + fun providePdfService(): PdfService = mockk() + + @Provides + @Singleton + fun provideOnboardingRepository( + fakeOnboardingRepository: FakeOnboardingRepository, + ): OnboardingRepository = fakeOnboardingRepository + + @Provides + @Singleton + fun provideInvitationCodeRepository(): InvitationCodeRepository = mockk() + + @Provides + @Singleton + fun provideSequentialOnboardingRepository(): SequentialOnboardingRepository = mockk() + + @Provides + @Singleton + fun provideOnConsentRepository(): ConsentManager = mockk() +} diff --git a/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/fakes/FakeOnboardingRepository.kt b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/fakes/FakeOnboardingRepository.kt new file mode 100644 index 000000000..dae1bded7 --- /dev/null +++ b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/fakes/FakeOnboardingRepository.kt @@ -0,0 +1,35 @@ +package edu.stanford.spezi.module.onboarding.fakes + +import edu.stanford.spezi.core.design.R +import edu.stanford.spezi.module.onboarding.onboarding.Area +import edu.stanford.spezi.module.onboarding.onboarding.OnboardingData +import edu.stanford.spezi.module.onboarding.onboarding.OnboardingRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FakeOnboardingRepository @Inject constructor() : OnboardingRepository { + private var onContinueAction: (() -> Unit)? = null + + override suspend fun getOnboardingData(): Result { + return Result.success( + OnboardingData( + title = "Onboarding screen", + subTitle = "Onboarding screen subtitle", + continueButtonText = "Learn more", + continueButtonAction = onContinueAction ?: {}, + areas = listOf( + Area( + title = "Area 1", + iconId = R.drawable.ic_vital_signs, + description = "Area 1 description" + ) + ) + ) + ) + } + + fun setOnContinueAction(action: () -> Unit) { + onContinueAction = action + } +} diff --git a/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/simulator/OnboardingScreenSimulator.kt b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/simulator/OnboardingScreenSimulator.kt new file mode 100644 index 000000000..2e02cc804 --- /dev/null +++ b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/simulator/OnboardingScreenSimulator.kt @@ -0,0 +1,71 @@ +package edu.stanford.spezi.module.onboarding.simulator + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.performClick +import edu.stanford.spezi.core.testing.onNodeWithIdentifier +import edu.stanford.spezi.module.onboarding.onboarding.OnboardingScreenTestIdentifier + +class OnboardingScreenSimulator( + private val composeTestRule: ComposeTestRule, +) { + private val root = composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.ROOT) + private val title = composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.TITLE) + private val subtitle = + composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.SUBTITLE) + private val areasList = + composeTestRule.onNodeWithIdentifier(OnboardingScreenTestIdentifier.AREAS_LIST) + private val button = composeTestRule.onNodeWithIdentifier( + identifier = OnboardingScreenTestIdentifier.LEARN_MORE_BUTTON, + useUnmergedTree = true, + ) + + fun assertDisplayed() { + root.assertIsDisplayed() + } + + fun assertTitle(text: String) { + title + .assertIsDisplayed() + .assertTextEquals(text) + } + + fun assertSubtitle(text: String) { + subtitle + .assertIsDisplayed() + .assertTextEquals(text) + } + + fun assertContinueButtonTitle(text: String) { + button + .onChild() + .assert(hasText(text)) + } + + fun onAreasList(scope: AreasSimulator.() -> Unit) { + AreasSimulator().apply(scope) + } + + fun clickContinueButton() { + button + .assertHasClickAction() + .performClick() + } + + inner class AreasSimulator { + fun assertDisplayed() { + areasList.assertIsDisplayed() + } + + fun assertAreaTitle(title: String) { + composeTestRule + .onNodeWithIdentifier(OnboardingScreenTestIdentifier.AREA_TITLE, title) + .assertIsDisplayed() + } + } +} diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentScreen.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentScreen.kt index 7beac44e2..31f7e682c 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentScreen.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentScreen.kt @@ -16,6 +16,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import edu.stanford.spezi.core.design.component.markdown.MarkdownComponent import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.utils.extensions.testIdentifier @Composable fun ConsentScreen() { @@ -32,7 +33,11 @@ private fun ConsentScreen( uiState: ConsentUiState, onAction: (ConsentAction) -> Unit, ) { - Column(modifier = Modifier.padding(Spacings.medium)) { + Column( + modifier = Modifier + .testIdentifier(ConsentScreenTestIdentifier.ROOT) + .padding(Spacings.medium) + ) { Spacer(modifier = Modifier.height(Spacings.medium)) MarkdownComponent(markdownElements = uiState.markdownElements) SignaturePad( @@ -66,3 +71,7 @@ private class ConsentScreenPreviewProvider : PreviewParameterProvider() val uiState by viewModel.uiState.collectAsState() + OnboardingScreen(uiState = uiState, onAction = viewModel::onAction) +} + +@Composable +fun OnboardingScreen( + uiState: OnboardingUiState, + onAction: (OnboardingAction) -> Unit, +) { Column( modifier = Modifier + .testIdentifier(OnboardingScreenTestIdentifier.ROOT) .fillMaxSize() .padding(Spacings.medium), verticalArrangement = Arrangement.SpaceBetween, @@ -50,13 +60,18 @@ fun OnboardingScreen() { ) { Text( text = uiState.title, + modifier = Modifier.testIdentifier(OnboardingScreenTestIdentifier.TITLE), style = titleLarge ) - Text(text = uiState.subtitle, style = bodyLarge) + Text( + text = uiState.subtitle, + modifier = Modifier.testIdentifier(OnboardingScreenTestIdentifier.SUBTITLE), + style = bodyLarge + ) Spacer(modifier = Modifier.height(Spacings.small)) - LazyColumn { + LazyColumn(modifier = Modifier.testIdentifier(OnboardingScreenTestIdentifier.AREAS_LIST)) { items(uiState.areas) { area -> FeatureItem(area = area) Spacer(modifier = Modifier.height(Spacings.medium)) @@ -64,10 +79,12 @@ fun OnboardingScreen() { } } Button( - onClick = { viewModel.onAction(Action.ContinueButtonAction) }, - modifier = Modifier.fillMaxWidth(), + onClick = { onAction(OnboardingAction.Continue) }, + modifier = Modifier + .testIdentifier(OnboardingScreenTestIdentifier.LEARN_MORE_BUTTON) + .fillMaxWidth(), ) { - Text("Learn More") + Text(text = uiState.continueButtonText) } } } @@ -90,6 +107,7 @@ fun FeatureItem(area: Area) { Column { Text( text = area.title, + modifier = Modifier.testIdentifier(OnboardingScreenTestIdentifier.AREA_TITLE, area.title), style = titleSmall ) Text( @@ -99,3 +117,12 @@ fun FeatureItem(area: Area) { } } } + +enum class OnboardingScreenTestIdentifier { + ROOT, + TITLE, + SUBTITLE, + AREAS_LIST, + AREA_TITLE, + LEARN_MORE_BUTTON, +} diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingUiState.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingUiState.kt index b0521c91a..f8300e283 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingUiState.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingUiState.kt @@ -3,10 +3,8 @@ package edu.stanford.spezi.module.onboarding.onboarding /** * A sealed class representing the actions that can be performed on the onboarding screen. */ -sealed class Action { - data class UpdateArea(val areas: List) : Action() - - data object ContinueButtonAction : Action() +sealed interface OnboardingAction { + data object Continue : OnboardingAction } /** @@ -16,5 +14,7 @@ data class OnboardingUiState( val areas: List = emptyList(), val title: String = "Title", val subtitle: String = "Subtitle", + val continueButtonText: String = "Continue", + val continueAction: () -> Unit = {}, val error: String? = null, ) diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingViewModel.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingViewModel.kt index 5b5401650..4bb270435 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingViewModel.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingViewModel.kt @@ -21,41 +21,33 @@ class OnboardingViewModel @Inject internal constructor( init() } - fun onAction(action: Action) { - _uiState.update { - when (action) { - is Action.UpdateArea -> { - val newAreas = action.areas - it.copy(areas = newAreas) - } - - Action.ContinueButtonAction -> { - viewModelScope.launch { - val onboardingData = repository.getOnboardingData().getOrNull() - onboardingData?.continueButtonAction?.invoke() - } - it - } + fun onAction(action: OnboardingAction) { + when (action) { + OnboardingAction.Continue -> { + _uiState.value.continueAction.invoke() } } } private fun init() { viewModelScope.launch { - val result = repository.getOnboardingData() - if (result.isSuccess) { - _uiState.update { - it.copy( - areas = result.getOrNull()?.areas ?: emptyList(), - title = result.getOrNull()?.title ?: "", - subtitle = result.getOrNull()?.subTitle ?: "" - ) + repository.getOnboardingData() + .onSuccess { onboardingData -> + _uiState.update { + it.copy( + areas = onboardingData.areas, + title = onboardingData.title, + subtitle = onboardingData.subTitle, + continueButtonText = onboardingData.continueButtonText, + continueAction = onboardingData.continueButtonAction, + ) + } } - } else { - _uiState.update { - it.copy(error = result.exceptionOrNull()?.message ?: "Unknown error") + .onFailure { error -> + _uiState.update { + it.copy(error = error.message ?: "Unknown error") + } } - } } } } diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/SequentialOnboardingScreen.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/SequentialOnboardingScreen.kt index 30d861c50..0249cc550 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/SequentialOnboardingScreen.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/SequentialOnboardingScreen.kt @@ -31,6 +31,7 @@ import edu.stanford.spezi.core.design.theme.Colors.primary import edu.stanford.spezi.core.design.theme.Colors.secondary import edu.stanford.spezi.core.design.theme.Colors.tertiary import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.utils.extensions.testIdentifier import edu.stanford.spezi.module.onboarding.sequential.components.OnboardingViewPage import edu.stanford.spezi.module.onboarding.sequential.components.PageIndicator @@ -97,22 +98,32 @@ fun SequentialOnboardingScreen( Column( modifier = Modifier + .testIdentifier(SequentialOnboardingScreenTestIdentifier.ROOT) .fillMaxSize() .background(currentPageColor) ) { HorizontalPager( state, - modifier = Modifier.weight(1f) - ) { step -> + modifier = Modifier + .testIdentifier(SequentialOnboardingScreenTestIdentifier.PAGER) + .weight(1f) + ) { index -> + val step = uiState.steps[index] OnboardingViewPage( + modifier = Modifier + .testIdentifier( + identifier = SequentialOnboardingScreenTestIdentifier.PAGE, + suffix = step.title + ), backgroundColor = currentPageColor, onColor = currentOnColor, - title = uiState.steps[step].title, - description = uiState.steps[step].description, - iconId = uiState.steps[step].icon, + title = step.title, + description = step.description, + iconId = step.icon, ) } PageIndicator( + modifier = Modifier.testIdentifier(SequentialOnboardingScreenTestIdentifier.PAGE_INDICATOR), currentPage = uiState.currentPage, pageCount = uiState.pageCount, backgroundColor = currentPageColor, @@ -176,3 +187,10 @@ private class SequentialOnboardingUiStateProvider : ) ) } + +enum class SequentialOnboardingScreenTestIdentifier { + ROOT, + PAGER, + PAGE, + PAGE_INDICATOR, +} diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/components/OnboardingViewPage.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/components/OnboardingViewPage.kt index 1fa4e0b03..7053d34ff 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/components/OnboardingViewPage.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/components/OnboardingViewPage.kt @@ -30,6 +30,7 @@ import edu.stanford.spezi.core.design.theme.TextStyles */ @Composable fun OnboardingViewPage( + modifier: Modifier = Modifier, backgroundColor: Color, onColor: Color, title: String, @@ -37,7 +38,7 @@ fun OnboardingViewPage( iconId: Int, ) { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(backgroundColor), contentAlignment = Alignment.Center diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/components/PageIndicator.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/components/PageIndicator.kt index 6d30b3119..aaa36b4d6 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/components/PageIndicator.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/sequential/components/PageIndicator.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import edu.stanford.spezi.core.utils.extensions.testIdentifier /** * A page indicator that shows the current page and the total number of pages. @@ -37,6 +38,7 @@ import androidx.compose.ui.unit.dp */ @Composable fun PageIndicator( + modifier: Modifier = Modifier, currentPage: Int, pageCount: Int, textColor: Color, @@ -46,12 +48,13 @@ fun PageIndicator( actionText: String, ) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .background(backgroundColor), verticalAlignment = Alignment.CenterVertically ) { TextButton( + modifier = Modifier.testIdentifier(PageIndicatorTestIdentifier.BACK), onClick = { onBack() }, @@ -95,6 +98,7 @@ fun PageIndicator( } Spacer(modifier = Modifier.weight(1f)) TextButton( + modifier = Modifier.testIdentifier(PageIndicatorTestIdentifier.FORWARD), onClick = { onForward() }, content = { Text( @@ -141,3 +145,8 @@ private data class PageIndicatorPreviewParams( val textColor: Color, val backgroundColor: Color, ) + +enum class PageIndicatorTestIdentifier { + BACK, + FORWARD, +} diff --git a/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingViewModelTest.kt b/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingViewModelTest.kt index e04b0483c..3e177e751 100644 --- a/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingViewModelTest.kt +++ b/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/onboarding/OnboardingViewModelTest.kt @@ -38,15 +38,10 @@ class OnboardingViewModelTest { @Test fun `it should invoke continueButtonAction on ContinueButtonAction`() = runTestUnconfined { // given - coEvery { repository.getOnboardingData() } returns Result.success(onboardingData) - val onboardingViewModel = OnboardingViewModel(repository) - val action = Action.ContinueButtonAction val continueButtonAction: () -> Unit = mockk(relaxed = true) - coEvery { repository.getOnboardingData() } returns Result.success( - OnboardingData( - continueButtonAction = continueButtonAction - ) - ) + coEvery { repository.getOnboardingData() } returns Result.success(onboardingData.copy(continueButtonAction = continueButtonAction)) + val onboardingViewModel = OnboardingViewModel(repository) + val action = OnboardingAction.Continue // when onboardingViewModel.onAction(action) @@ -67,5 +62,7 @@ class OnboardingViewModelTest { assertThat(onboardingData.areas).isEqualTo(uiState.areas) assertThat(onboardingData.title).isEqualTo(uiState.title) assertThat(onboardingData.subTitle).isEqualTo(uiState.subtitle) + assertThat(onboardingData.continueButtonText).isEqualTo(uiState.continueButtonText) + assertThat(onboardingData.continueButtonAction).isEqualTo(uiState.continueAction) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index c0875daed..c162c0d52 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,10 +33,10 @@ include(":core:bluetooth") include(":core:coroutines") include(":core:design") include(":core:logging") +include(":core:navigation") include(":core:testing") include(":core:utils") +include(":modules:account") include(":modules:contact") include(":modules:healthconnectonfhir") include(":modules:onboarding") -include(":modules:account") -include(":core:navigation")