Test infra: shared accessibility IDs + PageObjects + AAA_SeedTests
Ports iOS HoneyDueUITests AccessibilityIdentifiers + PageObjects pattern to Android Compose UI Test. Kotlin AccessibilityIds object mirrors Swift verbatim so scripts/verify_test_tag_parity.sh can gate on divergence. AAA_SeedTests bracketed first alphanumerically; SuiteZZ cleanup to follow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.tt.honeyDue.testing.AccessibilityIds
|
||||
import com.tt.honeyDue.ui.screens.MainTabScreen
|
||||
import com.tt.honeyDue.ui.screens.Screens
|
||||
|
||||
/**
|
||||
* Reusable helpers that mirror `iosApp/HoneyDueUITests/UITestHelpers.swift`.
|
||||
*
|
||||
* Each helper drives off [com.tt.honeyDue.testing.AccessibilityIds] so the
|
||||
* same semantic contract holds across iOS and Android. When the production
|
||||
* app changes authentication or nav flow, update these helpers rather than
|
||||
* every individual test.
|
||||
*/
|
||||
object UITestHelpers {
|
||||
/** Default credentials for the seeded "testuser" account (matches iOS). */
|
||||
const val DEFAULT_TEST_USERNAME = "testuser"
|
||||
const val DEFAULT_TEST_PASSWORD = "TestPass123!"
|
||||
|
||||
private fun tagNode(rule: ComposeTestRule, testTag: String): SemanticsNodeInteraction =
|
||||
rule.onNodeWithTag(testTag, useUnmergedTree = true)
|
||||
|
||||
/** Non-throwing existence check for a test-tag semantics node. */
|
||||
private fun exists(rule: ComposeTestRule, testTag: String): Boolean = try {
|
||||
tagNode(rule, testTag).assertExists()
|
||||
true
|
||||
} catch (e: AssertionError) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Waits up to [timeoutMs] for a semantics node with [testTag] to exist. */
|
||||
private fun waitForTag(
|
||||
rule: ComposeTestRule,
|
||||
testTag: String,
|
||||
timeoutMs: Long = 10_000L,
|
||||
): Boolean = try {
|
||||
rule.waitUntil(timeoutMs) { exists(rule, testTag) }
|
||||
true
|
||||
} catch (e: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun isOnLoginScreen(rule: ComposeTestRule): Boolean =
|
||||
exists(rule, AccessibilityIds.Authentication.usernameField)
|
||||
|
||||
private fun isLoggedIn(rule: ComposeTestRule): Boolean =
|
||||
exists(rule, AccessibilityIds.Navigation.residencesTab)
|
||||
|
||||
/**
|
||||
* Logs out if currently signed in. Noop if already on the login screen.
|
||||
*/
|
||||
fun logout(rule: ComposeTestRule) {
|
||||
if (waitForTag(rule, AccessibilityIds.Authentication.usernameField, timeoutMs = 2_000L)) return
|
||||
if (!isLoggedIn(rule)) return
|
||||
|
||||
val tabs = MainTabScreen(rule)
|
||||
tabs.goToSettings()
|
||||
// Some builds back the logout behind an outer profile tab instead;
|
||||
// either path converges on the `Profile.LogoutButton` test tag.
|
||||
if (waitForTag(rule, AccessibilityIds.Profile.logoutButton, timeoutMs = 5_000L)) {
|
||||
tabs.tapLogout()
|
||||
}
|
||||
|
||||
// Wait until we transition back to login (15s budget matches iOS).
|
||||
waitForTag(rule, AccessibilityIds.Authentication.usernameField, timeoutMs = 15_000L)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in using the provided credentials. Waits for the main tabs to
|
||||
* appear before returning. Throws if the tab bar never shows up.
|
||||
*/
|
||||
fun loginAsTestUser(
|
||||
rule: ComposeTestRule,
|
||||
username: String = DEFAULT_TEST_USERNAME,
|
||||
password: String = DEFAULT_TEST_PASSWORD,
|
||||
): MainTabScreen {
|
||||
ensureOnLoginScreen(rule)
|
||||
val tabs = Screens.login(rule).login(username, password)
|
||||
waitForTag(rule, AccessibilityIds.Navigation.residencesTab, timeoutMs = 15_000L)
|
||||
return tabs
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort navigation to the login screen from whatever state the app
|
||||
* is in. If we are already logged in, logs the user out first.
|
||||
*/
|
||||
fun ensureOnLoginScreen(rule: ComposeTestRule) {
|
||||
if (isOnLoginScreen(rule)) return
|
||||
if (isLoggedIn(rule)) {
|
||||
logout(rule)
|
||||
if (isOnLoginScreen(rule)) return
|
||||
}
|
||||
// Onboarding flow: tap the "login" affordance if present.
|
||||
if (exists(rule, AccessibilityIds.Onboarding.loginButton)) {
|
||||
tagNode(rule, AccessibilityIds.Onboarding.loginButton).performClick()
|
||||
}
|
||||
waitForTag(rule, AccessibilityIds.Authentication.usernameField, timeoutMs = 20_000L)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the UI to a known-good starting state. Called from test
|
||||
* teardown so residual state from one test doesn't poison the next.
|
||||
*/
|
||||
fun tearDown(rule: ComposeTestRule) {
|
||||
try {
|
||||
logout(rule)
|
||||
} catch (t: Throwable) {
|
||||
// Swallow — teardown must never fail a test.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user