Complete migration to Compose (#194)

* Migrate disclaimer screen to Compose

* Migrate base URL screen to Compose

* Migrate base URL screen to Compose

* Migrate authentication screen to Compose

* Initialize add recipe screen

* Remove unused resources

* Display add recipe operation result

* Add delete icon to ingredients and instructions

* Allow navigating between fields on add recipe

* Allow navigating between fields on authentication screen

* Allow to proceed from keyboard on base url screen

* Use material icons for recipe item

* Expose base URL as flow

* Initialize Compose navigation

* Allow sending logs again

* Allow to override navigation and top bar per screen

* Add additional logs

* Migrate share recipe screen to Compose

* Fix unit tests

* Restore recipe list tests

* Ensure authentication is shown after URL input

* Add autofill to authentication

* Complete first set up test

* Use image vector from Icons instead of drawable

* Add transition animations

* Fix logging host in Host header

* Do not fail test if login token is used
This commit is contained in:
Kirill Kamakin
2024-01-13 11:28:10 +01:00
committed by GitHub
parent 94f12820bc
commit de4df95a0e
107 changed files with 3294 additions and 2321 deletions

View File

@@ -1,9 +1,11 @@
package gq.kirmanak.mealient
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.screen.AuthenticationScreen
import gq.kirmanak.mealient.screen.BaseUrlScreen
import gq.kirmanak.mealient.screen.DisclaimerScreen
import gq.kirmanak.mealient.screen.RecipesListScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.kakao.common.utilities.getResourceString
import org.junit.Before
import org.junit.Test
@@ -13,63 +15,137 @@ class FirstSetUpTest : BaseTestCase() {
@Before
fun dispatchUrls() {
mockWebServer.dispatch { url, _ ->
if (url == "/api/app/about") versionV1Response else notFoundResponse
mockWebServer.dispatch { request ->
when (request.path) {
"/api/app/about" -> versionV1Response
"/api/auth/token" -> {
if (request.body == expectedLoginRequest) {
loginTokenResponse
} else {
notFoundResponse
}
}
"/api/users/api-tokens" -> {
if (request.authorization == expectedApiTokenAuthorizationHeader) {
apiTokenResponse
} else {
notFoundResponse
}
}
"/api/recipes?page=1&perPage=150" -> {
if (request.authorization == expectedAuthorizationHeader ||
request.authorization == expectedApiTokenAuthorizationHeader
) {
recipeSummariesResponse
} else {
notFoundResponse
}
}
else -> notFoundResponse
}
}
}
@Test
fun test() = run {
step("Ensure button is disabled") {
DisclaimerScreen {
step("Disclaimer screen with disabled button") {
onComposeScreen<DisclaimerScreen>(mainActivityRule) {
okayButton {
isVisible()
isDisabled()
hasAnyText()
assertIsNotEnabled()
}
okayButtonText {
assertTextContains(getResourceString(R.string.fragment_disclaimer_button_okay))
}
disclaimerText {
isVisible()
hasText(R.string.fragment_disclaimer_main_text)
assertTextEquals(getResourceString(R.string.fragment_disclaimer_main_text))
}
}
}
step("Close disclaimer screen") {
DisclaimerScreen {
onComposeScreen<DisclaimerScreen>(mainActivityRule) {
okayButtonText {
assertTextEquals(getResourceString(R.string.fragment_disclaimer_button_okay))
}
okayButton {
isVisible()
isEnabled()
hasText(R.string.fragment_disclaimer_button_okay)
click()
assertIsEnabled()
performClick()
}
}
}
step("Enter mock server address and click proceed") {
BaseUrlScreen {
onComposeScreen<BaseUrlScreen>(mainActivityRule) {
progressBar {
isGone()
assertDoesNotExist()
}
urlInput {
isVisible()
edit.replaceText(mockWebServer.url("/").toString())
hasHint(R.string.fragment_authentication_input_hint_url)
performTextInput(mockWebServer.url("/").toString())
}
urlInputLabel {
assertTextEquals(getResourceString(R.string.fragment_authentication_input_hint_url))
}
proceedButtonText {
assertTextEquals(getResourceString(R.string.fragment_base_url_save))
}
proceedButton {
isVisible()
isEnabled()
hasText(R.string.fragment_base_url_save)
click()
assertIsEnabled()
performClick()
}
}
}
step("Check that empty list of recipes is shown") {
RecipesListScreen(mainActivityRule).apply {
errorText {
step("Check that authentication is shown") {
onComposeScreen<AuthenticationScreen>(mainActivityRule) {
emailInput {
assertIsDisplayed()
}
passwordInput {
assertIsDisplayed()
}
loginButton {
assertIsDisplayed()
}
}
}
step("Enter credentials and click proceed") {
onComposeScreen<AuthenticationScreen>(mainActivityRule) {
emailInput {
performTextInput("test@test.test")
}
passwordInput {
performTextInput("password")
}
loginButton {
performClick()
}
}
}
step("Check that empty recipes list is shown") {
onComposeScreen<RecipesListScreen>(mainActivityRule) {
emptyListErrorText {
assertTextEquals(getResourceString(R.string.fragment_recipes_list_no_recipes))
}
openDrawerButton {
assertIsDisplayed()
}
searchRecipesField {
assertIsDisplayed()
assertTextEquals(getResourceString(R.string.fragment_recipes_load_failure_toast_no_reason))
}
}
}

View File

@@ -0,0 +1,62 @@
package gq.kirmanak.mealient
import android.util.Log
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
val versionV1Response = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}""")
val notFoundResponse = MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/json")
.setBody("""{"detail":"Not found"}"""")
val expectedLoginRequest = """
username=test%40test.test&password=password
""".trimIndent()
val loginTokenResponse = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"access_token":"login-token"}""")
val apiTokenResponse = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"token":"api-token"}""")
val recipeSummariesResponse = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"items":[]}""")
val expectedApiTokenAuthorizationHeader = "Bearer login-token"
val expectedAuthorizationHeader = "Bearer api-token"
data class RequestToDispatch(
val path: String,
val body: String,
val authorization: String?,
) {
constructor(recordedRequest: RecordedRequest) : this(
path = recordedRequest.path.orEmpty(),
body = recordedRequest.body.readUtf8(),
authorization = recordedRequest.getHeader("Authorization")
)
}
fun MockWebServer.dispatch(block: (RequestToDispatch) -> MockResponse) {
dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val requestToDispatch = RequestToDispatch(request)
Log.d("MockWebServer", "request = $requestToDispatch")
return block(requestToDispatch)
}
}
}

View File

@@ -1,24 +0,0 @@
package gq.kirmanak.mealient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
val versionV1Response = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}""")
val notFoundResponse = MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/json")
.setBody("""{"detail":"Not found"}"""")
fun MockWebServer.dispatch(block: (String, RecordedRequest) -> MockResponse) {
dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return block(request.path.orEmpty(), request)
}
}
}

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.screen
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
class AuthenticationScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<AuthenticationScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("authentication-screen") },
) {
val emailInput = child<KNode> { hasTestTag("email-input") }
val passwordInput = child<KNode> { hasTestTag("password-input") }
val loginButton = child<KNode> { hasTestTag("login-button") }
}

View File

@@ -1,19 +1,25 @@
package gq.kirmanak.mealient.screen
import com.kaspersky.kaspresso.screens.KScreen
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragment
import io.github.kakaocup.kakao.edit.KTextInputLayout
import io.github.kakaocup.kakao.progress.KProgressBar
import io.github.kakaocup.kakao.text.KButton
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
object BaseUrlScreen : KScreen<BaseUrlScreen>() {
override val layoutId = R.layout.fragment_base_url
override val viewClass = BaseURLFragment::class.java
class BaseUrlScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<BaseUrlScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("base-url-screen") },
) {
val urlInput = KTextInputLayout { withId(R.id.url_input_layout) }
val urlInput = child<KNode> { hasTestTag("url-input-field") }
val proceedButton = KButton { withId(R.id.button) }
val urlInputLabel = unmergedChild<KNode, BaseUrlScreen> { hasTestTag("url-input-label") }
val proceedButton = child<KNode> { hasTestTag("proceed-button") }
val proceedButtonText =
unmergedChild<KNode, BaseUrlScreen> { hasTestTag("proceed-button-text") }
val progressBar = unmergedChild<KNode, BaseUrlScreen> { hasTestTag("progress-indicator") }
val progressBar = KProgressBar { withId(R.id.progress)}
}

View File

@@ -1,16 +1,19 @@
package gq.kirmanak.mealient.screen
import com.kaspersky.kaspresso.screens.KScreen
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment
import io.github.kakaocup.kakao.text.KButton
import io.github.kakaocup.kakao.text.KTextView
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
object DisclaimerScreen : KScreen<DisclaimerScreen>() {
override val layoutId = R.layout.fragment_disclaimer
override val viewClass = DisclaimerFragment::class.java
internal class DisclaimerScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<DisclaimerScreen>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag("disclaimer-screen") },
) {
val okayButton = KButton { withId(R.id.okay) }
val okayButton = child<KNode> { hasTestTag("okay-button") }
val disclaimerText = KTextView { withId(R.id.main_text) }
val okayButtonText = unmergedChild<KNode, DisclaimerScreen> { hasTestTag("okay-button-text") }
val disclaimerText = child<KNode> { hasTestTag("disclaimer-text") }
}

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.screen
import io.github.kakaocup.compose.node.builder.ViewBuilder
import io.github.kakaocup.compose.node.core.BaseNode
inline fun <reified N, T : BaseNode<T>> BaseNode<T>.unmergedChild(
function: ViewBuilder.() -> Unit,
): N = child<N> {
useUnmergedTree = true
function()
}

View File

@@ -1,20 +1,18 @@
package gq.kirmanak.mealient.screen
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import org.junit.rules.TestRule
class RecipesListScreen<R : TestRule, A : ComponentActivity>(
semanticsProvider: AndroidComposeTestRule<R, A>,
) : ComposeScreen<RecipesListScreen<R, A>>(semanticsProvider) {
internal class RecipesListScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<RecipesListScreen>(semanticsProvider) {
init {
semanticsProvider.onRoot(useUnmergedTree = true).printToLog("RecipesListScreen")
val openDrawerButton = child<KNode> { hasTestTag("open-drawer-button") }
val searchRecipesField = child<KNode> { hasTestTag("search-recipes-field") }
val emptyListErrorText = unmergedChild<KNode, RecipesListScreen> {
hasTestTag("empty-list-error-text")
}
val errorText: KNode = child { hasTestTag("empty-list-error-text") }
}