diff --git a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt index b159bfc7..f46be3a7 100644 --- a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt +++ b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt @@ -28,7 +28,7 @@ package io.spine.internal.dependency // https://github.com/spine-examples/Pingh public object Pingh { - private const val version = "1.0.0-SNAPSHOT.10" + private const val version = "1.0.0-SNAPSHOT.11" private const val group = "io.spine.examples.pingh" public const val client: String = "$group:client:$version" diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Mentions.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Mentions.kt index 82b2686d..519ae843 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Mentions.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Mentions.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.spine.examples.pingh.client.MentionsFlow @@ -123,7 +124,7 @@ private fun ToolBar( IconButton( icon = Icons.pingh, onClick = toSettingsPage, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(40.dp).testTag("settings-button"), colors = IconButtonDefaults.iconButtonColors( contentColor = contentColor ) @@ -166,7 +167,8 @@ private fun MentionCards( .fillMaxSize() .padding(horizontal = 5.dp) .verticalScroll(scrollState) - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.background) + .testTag("mention-cards"), ) { mentions.sorted() .forEach { mention -> @@ -213,6 +215,7 @@ private fun MentionCard( onClick = onClick, modifier = Modifier .fillMaxWidth() + .testTag("mention-card-${mention.id}") .height(50.dp), interactionSource = interactionSource, colors = CardDefaults.elevatedCardColors( @@ -295,7 +298,7 @@ private fun SnoozeButton( onClick = { flow.snooze(mention.id) }, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(40.dp).testTag("snooze-button"), colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.onSecondary ) diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Settings.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Settings.kt index dfd88b5a..01ff2859 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Settings.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Settings.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics @@ -245,7 +246,7 @@ private fun LogOutButton( ) { OutlinedButton( onClick = onClick, - modifier = Modifier.height(20.dp), + modifier = Modifier.height(20.dp).testTag("logout-button"), colors = ButtonDefaults.outlinedButtonColors( containerColor = MaterialTheme.colorScheme.secondary, contentColor = MaterialTheme.colorScheme.onSecondary @@ -302,7 +303,8 @@ private fun DndOption( modifier = Modifier .scale(switchScale) .width(36.dp) - .height(20.dp), + .height(20.dp) + .testTag("dnd-option"), colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.secondary, checkedTrackColor = MaterialTheme.colorScheme.primary, diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Window.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Window.kt index 0690faab..2f88c42a 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Window.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Window.kt @@ -30,11 +30,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.Window as ComposeWindow @@ -47,21 +50,29 @@ import io.spine.examples.pingh.client.PinghApplication * * @param state The state of the application window. * @param app Manages the logic for the Pingh app. + * @param uriHandler The `CompositionLocal` that provides functionality for handling URL, + * such as opening a URI. * @see [PlatformWindow] */ @Composable -internal fun Window(state: WindowState, app: PinghApplication) { +internal fun Window( + state: WindowState, + app: PinghApplication, + uriHandler: UriHandler = LocalUriHandler.current +) { PlatformWindow( title = state.title, isShown = state.isShown, onClose = state::hide ) { - Box( - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.small) - ) { - CurrentPage(app) + CompositionLocalProvider(LocalUriHandler provides uriHandler) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.small) + ) { + CurrentPage(app) + } } } } diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt index d0d02c4e..d6e9eaee 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt @@ -46,8 +46,6 @@ import org.junit.jupiter.api.DisplayName @OptIn(ExperimentalTestApi::class) internal class LoginPageUiTest : UiTest() { - private val username = "MykytaPimonovTD" - private val SemanticsNodeInteractionsProvider.loginButton get() = onNodeWithTag("login-button") diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/MentionsPageUiTest.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/MentionsPageUiTest.kt new file mode 100644 index 00000000..355c4f67 --- /dev/null +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/MentionsPageUiTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.desktop + +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import io.kotest.matchers.floats.shouldBeLessThan +import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual +import io.kotest.matchers.shouldBe +import io.spine.examples.pingh.desktop.given.DelayedFactAssertion.Companion.awaitFact +import io.spine.examples.pingh.desktop.given.testTag +import kotlin.test.Test +import org.junit.jupiter.api.DisplayName + +@DisplayName("Mentions page should") +@OptIn(ExperimentalTestApi::class) +internal class MentionsPageUiTest : UiTest() { + + @Test + internal fun `allow users to open a mentions URL even after it has been read`() = + runComposeUiTest { + runApp() + logIn() + awaitFact { mentionCards().size shouldBeGreaterThanOrEqual 1 } + val tag = mentionCards().random().testTag + onNodeWithTag(tag).performClick() + awaitFact { openedUrlCount() shouldBe 1 } + onNodeWithTag(tag).performClick() + awaitFact { openedUrlCount() shouldBe 2 } + } + + @Test + internal fun `have snooze button disabled after it is clicked`() = + runComposeUiTest { + runApp() + logIn() + awaitFact { mentionCards().size shouldBeGreaterThanOrEqual 1 } + val tag = mentionCards().random().testTag + onSnoozeButtonWithParentTag(tag).performClick() + awaitFact { onSnoozeButtonWithParentTag(tag).assertDoesNotExist() } + } + + @Test + internal fun `have snooze button disabled if mention has been read`() = + runComposeUiTest { + runApp() + logIn() + awaitFact { mentionCards().size shouldBeGreaterThanOrEqual 1 } + val tag = mentionCards().random().testTag + onNodeWithTag(tag).performClick() + awaitFact { onSnoozeButtonWithParentTag(tag).assertDoesNotExist() } + } + + @Test + internal fun `have mentions sorted after their states have been changed`() = + runComposeUiTest { + runApp() + logIn() + awaitFact { mentionCards().size shouldBeGreaterThanOrEqual 2 } + val mentionsCards = mentionCards().sortedBy { it.positionInRoot.y } + val readMentionTag = mentionsCards[0].testTag + val snoozedMentionTag = mentionsCards[1].testTag + onNodeWithTag(readMentionTag).performClick() + onSnoozeButtonWithParentTag(snoozedMentionTag).performClick() + awaitFact { + val mentions = mentionCards() + val readMention = mentions.first { it.testTag == readMentionTag } + val snoozedMention = mentions.first { it.testTag == snoozedMentionTag } + snoozedMention.positionInRoot.y shouldBeLessThan readMention.positionInRoot.y + mentions.forEach { mention -> + if (mention != readMention && mention != snoozedMention) { + mention.positionInRoot.y shouldBeLessThan readMention.positionInRoot.y + mention.positionInRoot.y shouldBeLessThan snoozedMention.positionInRoot.y + } + } + } + } + + private fun SemanticsNodeInteractionsProvider.mentionCards(): List = + onNodeWithTag("mention-cards") + .onChildren() + .filter(hasClickAction()) + .fetchSemanticsNodes() + + private fun SemanticsNodeInteractionsProvider.onSnoozeButtonWithParentTag(tag: String): + SemanticsNodeInteraction = + onNodeWithTag(tag) + .onChildren() + .filterToOne(hasTestTag("snooze-button")) +} diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/SettingsPageUiTest.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/SettingsPageUiTest.kt new file mode 100644 index 00000000..92da5830 --- /dev/null +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/SettingsPageUiTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.desktop + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import io.spine.examples.pingh.desktop.given.DelayedFactAssertion.Companion.awaitFact +import kotlin.test.Test +import org.junit.jupiter.api.DisplayName + +@DisplayName("Settings page should") +@OptIn(ExperimentalTestApi::class) +internal class SettingsPageUiTest : UiTest() { + + private val SemanticsNodeInteractionsProvider.settingsButton + get() = onNodeWithTag("settings-button") + + private val SemanticsNodeInteractionsProvider.logoutButton + get() = onNodeWithTag("logout-button") + + private val SemanticsNodeInteractionsProvider.dndOption + get() = onNodeWithTag("dnd-option") + + @Test + internal fun `have settings retained after the user logs in again`() = + runComposeUiTest { + runApp() + logIn() + settingsButton.performClick() + awaitFact { dndOption.assertExists() } + dndOption.performClick() + awaitFact { dndOption.assertIsOn() } + logoutButton.performClick() + awaitFact { logoutButton.assertDoesNotExist() } + logIn() + settingsButton.performClick() + awaitFact { dndOption.assertIsOn() } + } +} diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/UiTest.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/UiTest.kt index 62f1004a..662faea6 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/UiTest.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/UiTest.kt @@ -29,6 +29,12 @@ package io.spine.examples.pingh.desktop import androidx.compose.runtime.remember import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import io.spine.examples.pingh.desktop.given.DelayedFactAssertion.Companion.awaitFact +import io.spine.examples.pingh.desktop.given.MemoizingUriHandler import io.spine.examples.pingh.testing.client.IntegrationTest import org.junit.jupiter.api.AfterEach @@ -38,11 +44,14 @@ import org.junit.jupiter.api.AfterEach */ internal abstract class UiTest : IntegrationTest() { + protected val username = "MykytaPimonovTD" private var state: AppState? = null + private val uriHandler = MemoizingUriHandler() @AfterEach internal fun shutdownChannel() { state?.app?.close() + uriHandler.reset() } /** @@ -54,8 +63,31 @@ internal abstract class UiTest : IntegrationTest() { Theme { val settings = retrieveSystemSettings() state = remember { AppState(settings) } - Window(state!!.window, state!!.app) + Window(state!!.window, state!!.app, uriHandler) } } } + + /** + * Returns the number of opened URLs. + * + * To correctly count opened URLs, the test application + * must be run using [runApp()][ComposeUiTest.runApp] method. + */ + protected fun openedUrlCount(): Int = uriHandler.urlCount + + /** + * Logs the user in the application with the specified username and + * navigates to the Mentions page. + */ + @OptIn(ExperimentalTestApi::class) + protected fun ComposeUiTest.logIn() { + onNodeWithTag("username-input").performTextInput(username) + awaitFact { onNodeWithTag("login-button").assertIsEnabled() } + onNodeWithTag("login-button").performClick() + awaitFact { onNodeWithTag("submit-button").assertExists() } + enterUserCode() + onNodeWithTag("submit-button").performClick() + awaitFact { onNodeWithTag("submit-button").assertDoesNotExist() } + } } diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/MemoizingUriHandler.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/MemoizingUriHandler.kt new file mode 100644 index 00000000..1278a519 --- /dev/null +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/MemoizingUriHandler.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.desktop.given + +import androidx.compose.ui.platform.UriHandler + +/** + * Stores the count of URLs that should be opened. + * + * Does not actually open any URLs. Intended for testing purposes only. + */ +internal class MemoizingUriHandler : UriHandler { + /** + * The number of URL that should be opened. + */ + internal var urlCount = 0 + private set + + /** + * Adds a URL to the [total count][urlCount] of URLs to be opened. + */ + override fun openUri(uri: String) { + urlCount++ + } + + /** + * Resets the count of opened URLs to zero. + */ + internal fun reset() { + urlCount = 0 + } +} diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt index c7b87d77..13effe40 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt @@ -26,6 +26,8 @@ package io.spine.examples.pingh.desktop.given +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties import com.google.common.util.concurrent.Uninterruptibles.sleepUninterruptibly import java.util.concurrent.TimeUnit import kotlin.time.Duration @@ -36,3 +38,11 @@ import kotlin.time.Duration internal fun delay(duration: Duration) { sleepUninterruptibly(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS) } + +/** + * Returns test tag attached to this semantics node. + */ +internal val SemanticsNode.testTag: String + get() = config.getOrElse(SemanticsProperties.TestTag) { + throw IllegalStateException("This node does not have a `TestTag` specified.") + } diff --git a/version.gradle.kts b/version.gradle.kts index 323fd74d..9d292934 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of the `Pingh` to publish. */ -val pinghVersion: String by extra("1.0.0-SNAPSHOT.10") +val pinghVersion: String by extra("1.0.0-SNAPSHOT.11")