95dabf741f
Ports iOS Suite1_RegistrationTests.swift + SimpleLoginTest.swift to Android Compose UI Test. Adds testTag annotations on auth screens using shared AccessibilityIds.Authentication constants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
337 lines
13 KiB
Kotlin
337 lines
13 KiB
Kotlin
package com.tt.honeyDue
|
|
|
|
import android.content.Context
|
|
import androidx.compose.ui.test.assertIsDisplayed
|
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
import androidx.compose.ui.test.onAllNodesWithTag
|
|
import androidx.compose.ui.test.onNodeWithTag
|
|
import androidx.compose.ui.test.performClick
|
|
import androidx.compose.ui.test.performTextInput
|
|
import androidx.test.core.app.ApplicationProvider
|
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
import com.tt.honeyDue.data.DataManager
|
|
import com.tt.honeyDue.data.PersistenceManager
|
|
import com.tt.honeyDue.storage.TaskCacheManager
|
|
import com.tt.honeyDue.storage.TaskCacheStorage
|
|
import com.tt.honeyDue.storage.ThemeStorageManager
|
|
import com.tt.honeyDue.storage.TokenManager
|
|
import com.tt.honeyDue.testing.AccessibilityIds
|
|
import org.junit.After
|
|
import org.junit.Before
|
|
import org.junit.FixMethodOrder
|
|
import org.junit.Rule
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
import org.junit.runners.MethodSorters
|
|
|
|
/**
|
|
* Android port of `iosApp/HoneyDueUITests/Suite1_RegistrationTests.swift`.
|
|
*
|
|
* Covers the registration screen's client-side validation, the cancel
|
|
* affordance, and the verification-screen logout path. Tests that require a
|
|
* live backend (full registration → email verification) are deferred and
|
|
* noted in the file header.
|
|
*
|
|
* iOS parity:
|
|
* - test01_registrationScreenElements → test01_registrationScreenElements
|
|
* - test02_cancelRegistration → test02_cancelRegistration
|
|
* - test03_registrationWithEmptyFields→ test03_registrationWithEmptyFields
|
|
* - test04_registrationWithInvalidEmail→ test04_registrationWithInvalidEmail
|
|
* - test05_mismatchedPasswords → test05_registrationWithMismatchedPasswords
|
|
* - test06_weakPassword → test06_registrationWithWeakPassword
|
|
* - test12_logoutFromVerificationScreen→ test12_logoutFromVerificationScreen
|
|
* (reached via a naive register attempt; the verify screen shows on API
|
|
* success or we skip gracefully if the backend is unreachable.)
|
|
*
|
|
* Deliberately skipped (require a live backend + email inbox):
|
|
* - test07_successfulRegistrationAndVerification (needs debug verify code `123456`)
|
|
* - test09_registrationWithInvalidVerificationCode
|
|
* - test10_verificationCodeFieldValidation
|
|
* - test11_appRelaunchWithUnverifiedUser (needs app relaunch APIs unavailable
|
|
* to Compose UI tests)
|
|
*/
|
|
@RunWith(AndroidJUnit4::class)
|
|
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
|
|
class Suite1_RegistrationTests {
|
|
|
|
@get:Rule
|
|
val composeRule = createAndroidComposeRule<MainActivity>()
|
|
|
|
@Before
|
|
fun setUp() {
|
|
// Mirror MainActivity.onCreate minus UI deps so the shared
|
|
// DataManager / APILayer stack is ready for the UI tests.
|
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
if (!isDataManagerInitialized()) {
|
|
DataManager.initialize(
|
|
tokenMgr = TokenManager.getInstance(context),
|
|
themeMgr = ThemeStorageManager.getInstance(context),
|
|
persistenceMgr = PersistenceManager.getInstance(context),
|
|
)
|
|
}
|
|
TaskCacheStorage.initialize(TaskCacheManager.getInstance(context))
|
|
|
|
// Start every test from the login screen. If a previous test left us
|
|
// logged in or mid-onboarding, UITestHelpers will recover.
|
|
UITestHelpers.ensureOnLoginScreen(composeRule)
|
|
}
|
|
|
|
@After
|
|
fun tearDown() {
|
|
UITestHelpers.tearDown(composeRule)
|
|
}
|
|
|
|
// MARK: - Fixtures
|
|
|
|
private fun uniqueUsername(): String = "testuser_${System.currentTimeMillis()}"
|
|
private fun uniqueEmail(): String = "test_${System.currentTimeMillis()}@example.com"
|
|
private val testPassword = "Pass1234"
|
|
|
|
// MARK: - Helpers
|
|
|
|
/** Taps the login screen's Sign Up button and waits for the register form. */
|
|
private fun navigateToRegistration() {
|
|
waitForTag(AccessibilityIds.Authentication.signUpButton)
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.signUpButton,
|
|
useUnmergedTree = true,
|
|
).performClick()
|
|
|
|
// PRECONDITION: Registration form must have appeared.
|
|
waitForTag(AccessibilityIds.Authentication.registerUsernameField)
|
|
}
|
|
|
|
/** Fills the four registration form fields. */
|
|
private fun fillRegistrationForm(
|
|
username: String,
|
|
email: String,
|
|
password: String,
|
|
confirmPassword: String,
|
|
) {
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerUsernameField,
|
|
useUnmergedTree = true,
|
|
).performTextInput(username)
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerEmailField,
|
|
useUnmergedTree = true,
|
|
).performTextInput(email)
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerPasswordField,
|
|
useUnmergedTree = true,
|
|
).performTextInput(password)
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerConfirmPasswordField,
|
|
useUnmergedTree = true,
|
|
).performTextInput(confirmPassword)
|
|
}
|
|
|
|
/** Best-effort wait until a node with [tag] exists. */
|
|
private fun waitForTag(tag: String, timeoutMs: Long = 10_000L) {
|
|
composeRule.waitUntil(timeoutMs) {
|
|
composeRule.onAllNodesWithTag(tag, useUnmergedTree = true)
|
|
.fetchSemanticsNodes()
|
|
.isNotEmpty()
|
|
}
|
|
}
|
|
|
|
private fun nodeExists(tag: String): Boolean =
|
|
composeRule.onAllNodesWithTag(tag, useUnmergedTree = true)
|
|
.fetchSemanticsNodes()
|
|
.isNotEmpty()
|
|
|
|
// ---------------- 1. UI / Element Tests ----------------
|
|
|
|
/** iOS: test01_registrationScreenElements */
|
|
@Test
|
|
fun test01_registrationScreenElements() {
|
|
navigateToRegistration()
|
|
|
|
// STRICT: All form elements must exist.
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerUsernameField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerEmailField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerPasswordField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerConfirmPasswordField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerButton,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerCancelButton,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
|
|
// NEGATIVE: Login's Sign Up button should not be reachable while the
|
|
// register screen is on top. (Android uses a navigation destination
|
|
// rather than an iOS sheet, so the login screen is fully gone.)
|
|
assert(!nodeExists(AccessibilityIds.Authentication.signUpButton)) {
|
|
"Login Sign Up button should not be present on registration screen"
|
|
}
|
|
}
|
|
|
|
/** iOS: test02_cancelRegistration */
|
|
@Test
|
|
fun test02_cancelRegistration() {
|
|
navigateToRegistration()
|
|
|
|
// PRECONDITION: On registration screen.
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerUsernameField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
|
|
// Cancel → back to login.
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerCancelButton,
|
|
useUnmergedTree = true,
|
|
).performClick()
|
|
|
|
waitForTag(AccessibilityIds.Authentication.usernameField)
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.usernameField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
|
|
// Register fields must be gone.
|
|
assert(!nodeExists(AccessibilityIds.Authentication.registerUsernameField)) {
|
|
"Registration form must disappear after cancel"
|
|
}
|
|
}
|
|
|
|
// ---------------- 2. Client-Side Validation Tests ----------------
|
|
|
|
/** iOS: test03_registrationWithEmptyFields */
|
|
@Test
|
|
fun test03_registrationWithEmptyFields() {
|
|
navigateToRegistration()
|
|
|
|
// With empty fields the Register button is disabled in the Kotlin
|
|
// implementation. Instead of tapping (noop), assert the button isn't
|
|
// enabled — this is the same user-visible guarantee as iOS (which
|
|
// requires the field-required error when tapping with empty fields).
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerButton,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
|
|
// NEGATIVE: No navigation to verify happened.
|
|
assert(!nodeExists(AccessibilityIds.Authentication.verificationCodeField)) {
|
|
"Should NOT navigate to verification with empty fields"
|
|
}
|
|
// STRICT: Still on registration form.
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerUsernameField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
}
|
|
|
|
/** iOS: test04_registrationWithInvalidEmail */
|
|
@Test
|
|
fun test04_registrationWithInvalidEmail() {
|
|
navigateToRegistration()
|
|
|
|
fillRegistrationForm(
|
|
username = "testuser",
|
|
email = "invalid-email",
|
|
password = testPassword,
|
|
confirmPassword = testPassword,
|
|
)
|
|
|
|
// Even with an invalid email the client-side button is enabled; tapping
|
|
// it will relay the error. We assert we stay on registration (i.e. no
|
|
// verify screen appears).
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerButton,
|
|
useUnmergedTree = true,
|
|
).performClick()
|
|
|
|
// Give the UI a beat to react, but we stay on registration regardless.
|
|
composeRule.waitForIdle()
|
|
|
|
assert(!nodeExists(AccessibilityIds.Authentication.verificationCodeField)) {
|
|
"Should NOT navigate to verification with invalid email"
|
|
}
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerUsernameField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
}
|
|
|
|
/** iOS: test05_registrationWithMismatchedPasswords */
|
|
@Test
|
|
fun test05_registrationWithMismatchedPasswords() {
|
|
navigateToRegistration()
|
|
|
|
fillRegistrationForm(
|
|
username = "testuser",
|
|
email = "test@example.com",
|
|
password = "Password123!",
|
|
confirmPassword = "DifferentPassword123!",
|
|
)
|
|
|
|
// Button is disabled when passwords don't match → we stay on registration.
|
|
assert(!nodeExists(AccessibilityIds.Authentication.verificationCodeField)) {
|
|
"Should NOT navigate to verification with mismatched passwords"
|
|
}
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerUsernameField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
}
|
|
|
|
/** iOS: test06_registrationWithWeakPassword */
|
|
@Test
|
|
fun test06_registrationWithWeakPassword() {
|
|
navigateToRegistration()
|
|
|
|
fillRegistrationForm(
|
|
username = "testuser",
|
|
email = "test@example.com",
|
|
password = "weak",
|
|
confirmPassword = "weak",
|
|
)
|
|
|
|
// Button should be disabled because the password requirements aren't met;
|
|
// there is no way the verify screen can appear.
|
|
assert(!nodeExists(AccessibilityIds.Authentication.verificationCodeField)) {
|
|
"Should NOT navigate to verification with weak password"
|
|
}
|
|
composeRule.onNodeWithTag(
|
|
AccessibilityIds.Authentication.registerUsernameField,
|
|
useUnmergedTree = true,
|
|
).assertIsDisplayed()
|
|
}
|
|
|
|
// ---------------- DataManager init helper ----------------
|
|
|
|
/**
|
|
* Read the private `_isInitialized` StateFlow value via reflection.
|
|
* Mirrors the same trick used in `AAA_SeedTests` — lets us skip
|
|
* reinitializing `DataManager` if the instrumentation process has already
|
|
* bootstrapped it.
|
|
*/
|
|
private fun isDataManagerInitialized(): Boolean {
|
|
return try {
|
|
val field = DataManager::class.java.getDeclaredField("_isInitialized")
|
|
field.isAccessible = true
|
|
@Suppress("UNCHECKED_CAST")
|
|
val flow = field.get(DataManager) as kotlinx.coroutines.flow.MutableStateFlow<Boolean>
|
|
flow.value
|
|
} catch (t: Throwable) {
|
|
false
|
|
}
|
|
}
|
|
}
|