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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
Reference in New Issue
Block a user