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() @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() 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 flow.value } catch (t: Throwable) { false } } }