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:
Trey T
2026-04-18 14:19:37 -05:00
parent 42c21bfca1
commit 2d80ade6bc
12 changed files with 1076 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
package com.tt.honeyDue
import android.content.Context
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.fixtures.TestResidence
import com.tt.honeyDue.fixtures.TestTask
import com.tt.honeyDue.fixtures.TestUser
import com.tt.honeyDue.models.LoginRequest
import com.tt.honeyDue.models.RegisterRequest
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
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 kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
/**
* Phase 1 — Seed tests run sequentially before the parallel suites.
*
* Ports `iosApp/HoneyDueUITests/AAA_SeedTests.swift`. The AAA prefix keeps
* these tests alphabetically first under JUnit's default sorter so seed
* state (a verified test user, a residence, a task) exists before
* `Suite*` tests run in parallel. `SuiteZZ_CleanupTests` (future) removes
* the leftover data at the end of a run.
*
* These hit the real dev backend configured in `ApiConfig.CURRENT_ENV`.
* If the backend is unreachable the tests fail fast — no silent skip.
*/
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class AAA_SeedTests {
private val testUser: TestUser = TestUser.seededTestUser()
private val adminUser: TestUser = TestUser.seededAdminUser()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
if (!DataManager.isInitializedValue()) {
// Mirror MainActivity.onCreate minus UI deps so APILayer has
// everything it needs to persist the auth token.
DataManager.initialize(
tokenMgr = TokenManager.getInstance(context),
themeMgr = ThemeStorageManager.getInstance(context),
persistenceMgr = PersistenceManager.getInstance(context),
)
}
// Task cache is consulted during prefetchAllData — initialize to
// avoid NPEs inside the APILayer success path.
TaskCacheStorage.initialize(TaskCacheManager.getInstance(context))
}
@Test
fun a01_seedTestUserCreated() = runBlocking {
// Try logging in first; account may already exist on the dev backend.
val loginResult = APILayer.login(
LoginRequest(username = testUser.username, password = testUser.password),
)
if (loginResult is ApiResult.Success) {
assertNotNull("Auth token must be populated after login", loginResult.data.token)
return@runBlocking
}
val registerResult = APILayer.register(
RegisterRequest(
username = testUser.username,
email = testUser.email,
password = testUser.password,
firstName = testUser.firstName,
lastName = testUser.lastName,
),
)
assertTrue(
"Expected to create seed testuser; got $registerResult",
registerResult is ApiResult.Success,
)
}
@Test
fun a02_seedAdminUserExists() = runBlocking {
val loginResult = APILayer.login(
LoginRequest(username = adminUser.username, password = adminUser.password),
)
if (loginResult is ApiResult.Success) {
assertNotNull("Auth token populated for admin login", loginResult.data.token)
return@runBlocking
}
val registerResult = APILayer.register(
RegisterRequest(
username = adminUser.username,
email = adminUser.email,
password = adminUser.password,
firstName = adminUser.firstName,
lastName = adminUser.lastName,
),
)
assertTrue(
"Expected to create seed admin; got $registerResult",
registerResult is ApiResult.Success,
)
}
@Test
fun a03_seedResidenceCreated() = runBlocking {
// Ensure we have a session for the test user.
val loginResult = APILayer.login(
LoginRequest(username = testUser.username, password = testUser.password),
)
assertTrue(
"Must be logged in as testuser before creating residence",
loginResult is ApiResult.Success,
)
val residenceResult = APILayer.createResidence(
TestResidence.house().toCreateRequest(),
)
assertTrue(
"Expected to create seed residence; got $residenceResult",
residenceResult is ApiResult.Success,
)
}
@Test
fun a04_seedTaskCreatedOnResidence() = runBlocking {
val loginResult = APILayer.login(
LoginRequest(username = testUser.username, password = testUser.password),
)
assertTrue(
"Must be logged in as testuser before creating task",
loginResult is ApiResult.Success,
)
// Use the first residence that comes back from `prefetchAllData`, which
// APILayer.login already kicked off. Fall back to creating one.
val residences = DataManager.residences.value
val residenceId = residences.firstOrNull()?.id
?: run {
val create = APILayer.createResidence(TestResidence.house().toCreateRequest())
(create as? ApiResult.Success)?.data?.id
?: error("Cannot create residence for task seed: $create")
}
val taskResult = APILayer.createTask(
TestTask.basic(residenceId = residenceId).toCreateRequest(),
)
assertTrue(
"Expected to create seed task; got $taskResult",
taskResult is ApiResult.Success,
)
}
// ---- Helpers ----
private fun DataManager.isInitializedValue(): Boolean {
// DataManager exposes `isInitialized` as a StateFlow<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 (e: Throwable) {
false
}
}
}

View File

@@ -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.
}
}
}

View File

@@ -0,0 +1,60 @@
package com.tt.honeyDue.fixtures
import com.tt.honeyDue.models.ResidenceCreateRequest
import kotlin.random.Random
/**
* Test residence fixture mirroring `TestFixtures.TestResidence` in Swift.
* Produces the exact payload shape the Go API expects so seed tests can
* call `APILayer.createResidence(fixture.toCreateRequest())`.
*/
data class TestResidence(
val name: String,
val streetAddress: String,
val city: String,
val stateProvince: String,
val postalCode: String,
val country: String = "USA",
val bedrooms: Int? = null,
val bathrooms: Double? = null,
val isPrimary: Boolean = false,
) {
fun toCreateRequest(): ResidenceCreateRequest = ResidenceCreateRequest(
name = name,
streetAddress = streetAddress,
city = city,
stateProvince = stateProvince,
postalCode = postalCode,
country = country,
bedrooms = bedrooms,
bathrooms = bathrooms,
isPrimary = isPrimary,
)
companion object {
fun house(suffix: String = randomSuffix()): TestResidence = TestResidence(
name = "Test House $suffix",
streetAddress = "123 Test St",
city = "Testville",
stateProvince = "CA",
postalCode = "94000",
bedrooms = 3,
bathrooms = 2.0,
isPrimary = true,
)
fun apartment(suffix: String = randomSuffix()): TestResidence = TestResidence(
name = "Test Apt $suffix",
streetAddress = "456 Mock Ave",
city = "Testville",
stateProvince = "CA",
postalCode = "94001",
bedrooms = 1,
bathrooms = 1.0,
isPrimary = false,
)
private fun randomSuffix(): String =
Random.nextInt(1000, 9999).toString()
}
}

View File

@@ -0,0 +1,51 @@
package com.tt.honeyDue.fixtures
import com.tt.honeyDue.models.TaskCreateRequest
import kotlin.random.Random
/**
* Test task fixture mirroring `TestFixtures.TestTask` in Swift.
*
* The Go API requires a residenceId and assigns category/priority IDs from
* DataManager lookups — callers pass the ID of a seeded test residence plus
* optional lookup IDs after a prefetch.
*/
data class TestTask(
val title: String,
val description: String,
val residenceId: Int,
val priorityId: Int? = null,
val categoryId: Int? = null,
val estimatedCost: Double? = null,
) {
fun toCreateRequest(): TaskCreateRequest = TaskCreateRequest(
residenceId = residenceId,
title = title,
description = description,
categoryId = categoryId,
priorityId = priorityId,
estimatedCost = estimatedCost,
)
companion object {
fun basic(residenceId: Int, suffix: String = randomSuffix()): TestTask = TestTask(
title = "Test Task $suffix",
description = "A test task",
residenceId = residenceId,
)
fun urgent(
residenceId: Int,
priorityId: Int? = null,
suffix: String = randomSuffix(),
): TestTask = TestTask(
title = "Urgent Task $suffix",
description = "An urgent task",
residenceId = residenceId,
priorityId = priorityId,
)
private fun randomSuffix(): String =
Random.nextInt(1000, 9999).toString()
}
}

View File

@@ -0,0 +1,48 @@
package com.tt.honeyDue.fixtures
import kotlin.random.Random
/**
* Test user fixture mirroring `TestFixtures.TestUser` in Swift.
*
* `seededTestUser()` yields the known-good backend account that
* `AAA_SeedTests` ensures exists before the parallel suites run.
*/
data class TestUser(
val username: String,
val email: String,
val password: String,
val firstName: String = "Test",
val lastName: String = "User",
) {
companion object {
/** Pre-existing user seeded against the dev backend. */
fun seededTestUser(): TestUser = TestUser(
username = "testuser",
email = "testuser@honeydue.com",
password = "TestPass123!",
)
/** Admin account used by admin-gated flows. */
fun seededAdminUser(): TestUser = TestUser(
username = "admin",
email = "admin@honeydue.com",
password = "Test1234",
)
/**
* Unique, ephemeral user used for registration flows that cannot
* re-use an existing account. Cleaned up by `SuiteZZ_CleanupTests`.
*/
fun ephemeralUser(suffix: String = randomSuffix()): TestUser = TestUser(
username = "uitest_$suffix",
email = "uitest_$suffix@test.com",
password = "TestPassword123!",
firstName = "Test",
lastName = "User",
)
private fun randomSuffix(): String =
Random.nextInt(100_000, 999_999).toString()
}
}

View File

@@ -0,0 +1,85 @@
package com.tt.honeyDue.ui
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
/**
* Base class for Android Compose UI test page objects.
*
* Mirrors `iosApp/HoneyDueUITests/PageObjects/BaseScreen.swift`: provides
* condition-based waits so screen objects can interact with Compose nodes
* without reaching for ad-hoc `Thread.sleep` calls.
*/
abstract class BaseScreen(protected val rule: ComposeTestRule) {
/** Returns a node interaction for the given test tag (unmerged tree for testability). */
protected fun tag(testTag: String): SemanticsNodeInteraction =
rule.onNodeWithTag(testTag, useUnmergedTree = true)
/** Returns a node interaction for the given display text. */
protected fun text(value: String): SemanticsNodeInteraction =
rule.onNodeWithText(value, useUnmergedTree = true)
/** Waits until a node with [testTag] exists in the semantics tree. */
protected fun waitFor(testTag: String, timeoutMs: Long = DEFAULT_TIMEOUT_MS) {
rule.waitUntil(timeoutMs) {
try {
tag(testTag).assertExists()
true
} catch (e: AssertionError) {
false
}
}
}
/** Waits until a node with the given visible [value] text exists. */
protected fun waitForText(value: String, timeoutMs: Long = DEFAULT_TIMEOUT_MS) {
rule.waitUntil(timeoutMs) {
try {
text(value).assertExists()
true
} catch (e: AssertionError) {
false
}
}
}
/** Waits until a node with [testTag] is actually displayed (not just present). */
protected fun waitForDisplayed(testTag: String, timeoutMs: Long = DEFAULT_TIMEOUT_MS) {
rule.waitUntil(timeoutMs) {
try {
tag(testTag).assertIsDisplayed()
true
} catch (e: AssertionError) {
false
}
}
}
/** Non-throwing existence check. */
protected fun exists(testTag: String): Boolean = try {
tag(testTag).assertExists()
true
} catch (e: AssertionError) {
false
}
/** Non-throwing text existence check. */
protected fun textExists(value: String): Boolean = try {
text(value).assertExists()
true
} catch (e: AssertionError) {
false
}
/** Subclasses report whether their screen is currently visible. */
abstract fun isDisplayed(): Boolean
companion object {
const val DEFAULT_TIMEOUT_MS: Long = 10_000L
const val SHORT_TIMEOUT_MS: Long = 5_000L
}
}

View File

@@ -0,0 +1,64 @@
package com.tt.honeyDue.ui.screens
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.ui.BaseScreen
/**
* Page object for the login screen.
* Mirrors `iosApp/HoneyDueUITests/PageObjects/LoginScreen.swift`.
*/
class LoginScreen(rule: ComposeTestRule) : BaseScreen(rule) {
fun enterUsername(value: String): LoginScreen {
waitFor(AccessibilityIds.Authentication.usernameField)
tag(AccessibilityIds.Authentication.usernameField).performTextInput(value)
return this
}
fun enterPassword(value: String): LoginScreen {
waitFor(AccessibilityIds.Authentication.passwordField)
tag(AccessibilityIds.Authentication.passwordField).performTextInput(value)
return this
}
fun tapLogin(): MainTabScreen {
waitFor(AccessibilityIds.Authentication.loginButton)
tag(AccessibilityIds.Authentication.loginButton).performClick()
return MainTabScreen(rule)
}
fun tapSignUp(): RegisterScreen {
waitFor(AccessibilityIds.Authentication.signUpButton)
tag(AccessibilityIds.Authentication.signUpButton).performClick()
return RegisterScreen(rule)
}
fun tapForgotPassword(): LoginScreen {
waitFor(AccessibilityIds.Authentication.forgotPasswordButton)
tag(AccessibilityIds.Authentication.forgotPasswordButton).performClick()
return this
}
fun togglePasswordVisibility(): LoginScreen {
waitFor(AccessibilityIds.Authentication.passwordVisibilityToggle)
tag(AccessibilityIds.Authentication.passwordVisibilityToggle).performClick()
return this
}
/** Convenience: enter credentials and submit. */
fun login(username: String, password: String): MainTabScreen {
enterUsername(username)
enterPassword(password)
return tapLogin()
}
/** Waits for the main tab bar to appear post-login. */
fun waitForMainTabs(timeoutMs: Long = DEFAULT_TIMEOUT_MS) {
waitFor(AccessibilityIds.Navigation.residencesTab, timeoutMs)
}
override fun isDisplayed(): Boolean = exists(AccessibilityIds.Authentication.usernameField)
}

View File

@@ -0,0 +1,63 @@
package com.tt.honeyDue.ui.screens
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.performClick
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.ui.BaseScreen
/**
* Page object for the main tab scaffold visible after login.
* Mirrors `iosApp/HoneyDueUITests/PageObjects/MainTabScreen.swift`.
*/
class MainTabScreen(rule: ComposeTestRule) : BaseScreen(rule) {
fun tapResidencesTab(): MainTabScreen {
waitFor(AccessibilityIds.Navigation.residencesTab)
tag(AccessibilityIds.Navigation.residencesTab).performClick()
return this
}
fun tapTasksTab(): MainTabScreen {
waitFor(AccessibilityIds.Navigation.tasksTab)
tag(AccessibilityIds.Navigation.tasksTab).performClick()
return this
}
fun tapContractorsTab(): MainTabScreen {
waitFor(AccessibilityIds.Navigation.contractorsTab)
tag(AccessibilityIds.Navigation.contractorsTab).performClick()
return this
}
fun tapDocumentsTab(): MainTabScreen {
waitFor(AccessibilityIds.Navigation.documentsTab)
tag(AccessibilityIds.Navigation.documentsTab).performClick()
return this
}
fun tapProfileTab(): MainTabScreen {
waitFor(AccessibilityIds.Navigation.profileTab)
tag(AccessibilityIds.Navigation.profileTab).performClick()
return this
}
/** Opens the settings/profile sheet via the settings affordance. */
fun goToSettings(): MainTabScreen {
tapResidencesTab()
waitFor(AccessibilityIds.Navigation.settingsButton)
tag(AccessibilityIds.Navigation.settingsButton).performClick()
return this
}
/**
* Taps logout from the profile sheet. Caller is responsible for waiting
* on the logout confirmation dialog if the app shows one.
*/
fun tapLogout(): MainTabScreen {
waitFor(AccessibilityIds.Profile.logoutButton)
tag(AccessibilityIds.Profile.logoutButton).performClick()
return this
}
override fun isDisplayed(): Boolean = exists(AccessibilityIds.Navigation.residencesTab)
}

View File

@@ -0,0 +1,63 @@
package com.tt.honeyDue.ui.screens
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.ui.BaseScreen
/**
* Page object for the registration screen.
* Mirrors `iosApp/HoneyDueUITests/PageObjects/RegisterScreen.swift`.
*/
class RegisterScreen(rule: ComposeTestRule) : BaseScreen(rule) {
fun enterUsername(value: String): RegisterScreen {
waitFor(AccessibilityIds.Authentication.registerUsernameField)
tag(AccessibilityIds.Authentication.registerUsernameField).performTextInput(value)
return this
}
fun enterEmail(value: String): RegisterScreen {
waitFor(AccessibilityIds.Authentication.registerEmailField)
tag(AccessibilityIds.Authentication.registerEmailField).performTextInput(value)
return this
}
fun enterPassword(value: String): RegisterScreen {
waitFor(AccessibilityIds.Authentication.registerPasswordField)
tag(AccessibilityIds.Authentication.registerPasswordField).performTextInput(value)
return this
}
fun enterConfirmPassword(value: String): RegisterScreen {
waitFor(AccessibilityIds.Authentication.registerConfirmPasswordField)
tag(AccessibilityIds.Authentication.registerConfirmPasswordField).performTextInput(value)
return this
}
fun tapRegister(): MainTabScreen {
waitFor(AccessibilityIds.Authentication.registerButton)
tag(AccessibilityIds.Authentication.registerButton).performClick()
return MainTabScreen(rule)
}
fun tapCancel(): LoginScreen {
waitFor(AccessibilityIds.Authentication.registerCancelButton)
tag(AccessibilityIds.Authentication.registerCancelButton).performClick()
return LoginScreen(rule)
}
/** Convenience: fill out the form and submit. */
fun register(username: String, email: String, password: String): MainTabScreen {
enterUsername(username)
enterEmail(email)
enterPassword(password)
enterConfirmPassword(password)
return tapRegister()
}
override fun isDisplayed(): Boolean =
exists(AccessibilityIds.Authentication.registerUsernameField) ||
exists(AccessibilityIds.Authentication.registerEmailField)
}

View File

@@ -0,0 +1,13 @@
package com.tt.honeyDue.ui.screens
import androidx.compose.ui.test.junit4.ComposeTestRule
/**
* Factory helpers for page objects — keeps test bodies concise.
* Mirrors `iosApp/HoneyDueUITests/PageObjects/Screens.swift`.
*/
object Screens {
fun login(rule: ComposeTestRule): LoginScreen = LoginScreen(rule)
fun register(rule: ComposeTestRule): RegisterScreen = RegisterScreen(rule)
fun mainTabs(rule: ComposeTestRule): MainTabScreen = MainTabScreen(rule)
}

View File

@@ -0,0 +1,289 @@
package com.tt.honeyDue.testing
/**
* Centralized accessibility identifiers for UI testing.
*
* 1:1 port of iOS `AccessibilityIdentifiers.swift` — string values MUST
* remain verbatim matches so `scripts/verify_test_tag_parity.sh` can gate
* cross-platform divergence. Production screens reference these via
* `Modifier.testTag(AccessibilityIds.Authentication.usernameField)` so the
* same test harness works identically across iOS and Android.
*/
object AccessibilityIds {
// MARK: - Authentication
object Authentication {
const val usernameField = "Login.UsernameField"
const val passwordField = "Login.PasswordField"
const val loginButton = "Login.LoginButton"
const val signUpButton = "Login.SignUpButton"
const val forgotPasswordButton = "Login.ForgotPasswordButton"
const val passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
const val appleSignInButton = "Login.AppleSignInButton"
const val googleSignInButton = "Login.GoogleSignInButton"
// Registration
const val registerUsernameField = "Register.UsernameField"
const val registerEmailField = "Register.EmailField"
const val registerPasswordField = "Register.PasswordField"
const val registerConfirmPasswordField = "Register.ConfirmPasswordField"
const val registerButton = "Register.RegisterButton"
const val registerCancelButton = "Register.CancelButton"
// Verification
const val verificationCodeField = "Verification.CodeField"
const val verifyButton = "Verification.VerifyButton"
const val resendCodeButton = "Verification.ResendButton"
}
// MARK: - Navigation
object Navigation {
const val residencesTab = "TabBar.Residences"
const val tasksTab = "TabBar.Tasks"
const val contractorsTab = "TabBar.Contractors"
const val documentsTab = "TabBar.Documents"
const val profileTab = "TabBar.Profile"
const val settingsButton = "Navigation.SettingsButton"
const val backButton = "Navigation.BackButton"
}
// MARK: - Residence
object Residence {
// List
const val addButton = "Residence.AddButton"
const val residencesList = "Residence.List"
const val residenceCard = "Residence.Card"
const val emptyStateView = "Residence.EmptyState"
const val emptyStateButton = "Residence.EmptyState.AddButton"
// Form
const val nameField = "ResidenceForm.NameField"
const val propertyTypePicker = "ResidenceForm.PropertyTypePicker"
const val streetAddressField = "ResidenceForm.StreetAddressField"
const val apartmentUnitField = "ResidenceForm.ApartmentUnitField"
const val cityField = "ResidenceForm.CityField"
const val stateProvinceField = "ResidenceForm.StateProvinceField"
const val postalCodeField = "ResidenceForm.PostalCodeField"
const val countryField = "ResidenceForm.CountryField"
const val bedroomsField = "ResidenceForm.BedroomsField"
const val bathroomsField = "ResidenceForm.BathroomsField"
const val squareFootageField = "ResidenceForm.SquareFootageField"
const val lotSizeField = "ResidenceForm.LotSizeField"
const val yearBuiltField = "ResidenceForm.YearBuiltField"
const val descriptionField = "ResidenceForm.DescriptionField"
const val isPrimaryToggle = "ResidenceForm.IsPrimaryToggle"
const val saveButton = "ResidenceForm.SaveButton"
const val formCancelButton = "ResidenceForm.CancelButton"
// Detail
const val detailView = "ResidenceDetail.View"
const val editButton = "ResidenceDetail.EditButton"
const val deleteButton = "ResidenceDetail.DeleteButton"
const val shareButton = "ResidenceDetail.ShareButton"
const val manageUsersButton = "ResidenceDetail.ManageUsersButton"
const val tasksSection = "ResidenceDetail.TasksSection"
const val addTaskButton = "ResidenceDetail.AddTaskButton"
}
// MARK: - Task
object Task {
// List/Kanban
const val addButton = "Task.AddButton"
const val refreshButton = "Task.RefreshButton"
const val tasksList = "Task.List"
const val taskCard = "Task.Card"
const val emptyStateView = "Task.EmptyState"
const val kanbanView = "Task.KanbanView"
const val overdueColumn = "Task.Column.Overdue"
const val upcomingColumn = "Task.Column.Upcoming"
const val inProgressColumn = "Task.Column.InProgress"
const val completedColumn = "Task.Column.Completed"
// Form
const val titleField = "TaskForm.TitleField"
const val descriptionField = "TaskForm.DescriptionField"
const val categoryPicker = "TaskForm.CategoryPicker"
const val frequencyPicker = "TaskForm.FrequencyPicker"
const val priorityPicker = "TaskForm.PriorityPicker"
const val statusPicker = "TaskForm.StatusPicker"
const val dueDatePicker = "TaskForm.DueDatePicker"
const val intervalDaysField = "TaskForm.IntervalDaysField"
const val estimatedCostField = "TaskForm.EstimatedCostField"
const val residencePicker = "TaskForm.ResidencePicker"
const val saveButton = "TaskForm.SaveButton"
const val formCancelButton = "TaskForm.CancelButton"
// Detail
const val detailView = "TaskDetail.View"
const val editButton = "TaskDetail.EditButton"
const val deleteButton = "TaskDetail.DeleteButton"
const val markInProgressButton = "TaskDetail.MarkInProgressButton"
const val completeButton = "TaskDetail.CompleteButton"
const val detailCancelButton = "TaskDetail.CancelButton"
// Completion
const val completionDatePicker = "TaskCompletion.CompletionDatePicker"
const val actualCostField = "TaskCompletion.ActualCostField"
const val ratingView = "TaskCompletion.RatingView"
const val notesField = "TaskCompletion.NotesField"
const val photosPicker = "TaskCompletion.PhotosPicker"
const val submitButton = "TaskCompletion.SubmitButton"
}
// MARK: - Contractor
object Contractor {
const val addButton = "Contractor.AddButton"
const val contractorsList = "Contractor.List"
const val contractorCard = "Contractor.Card"
const val emptyStateView = "Contractor.EmptyState"
// Form
const val nameField = "ContractorForm.NameField"
const val companyField = "ContractorForm.CompanyField"
const val emailField = "ContractorForm.EmailField"
const val phoneField = "ContractorForm.PhoneField"
const val specialtyPicker = "ContractorForm.SpecialtyPicker"
const val ratingView = "ContractorForm.RatingView"
const val notesField = "ContractorForm.NotesField"
const val saveButton = "ContractorForm.SaveButton"
const val formCancelButton = "ContractorForm.CancelButton"
// Detail
const val detailView = "ContractorDetail.View"
const val menuButton = "ContractorDetail.MenuButton"
const val editButton = "ContractorDetail.EditButton"
const val deleteButton = "ContractorDetail.DeleteButton"
const val callButton = "ContractorDetail.CallButton"
const val emailButton = "ContractorDetail.EmailButton"
}
// MARK: - Document
object Document {
const val addButton = "Document.AddButton"
const val documentsList = "Document.List"
const val documentCard = "Document.Card"
const val emptyStateView = "Document.EmptyState"
// Form
const val titleField = "DocumentForm.TitleField"
const val typePicker = "DocumentForm.TypePicker"
const val categoryPicker = "DocumentForm.CategoryPicker"
const val residencePicker = "DocumentForm.ResidencePicker"
const val filePicker = "DocumentForm.FilePicker"
const val notesField = "DocumentForm.NotesField"
const val expirationDatePicker = "DocumentForm.ExpirationDatePicker"
const val itemNameField = "DocumentForm.ItemNameField"
const val modelNumberField = "DocumentForm.ModelNumberField"
const val serialNumberField = "DocumentForm.SerialNumberField"
const val providerField = "DocumentForm.ProviderField"
const val providerContactField = "DocumentForm.ProviderContactField"
const val tagsField = "DocumentForm.TagsField"
const val locationField = "DocumentForm.LocationField"
const val saveButton = "DocumentForm.SaveButton"
const val formCancelButton = "DocumentForm.CancelButton"
// Detail
const val detailView = "DocumentDetail.View"
const val menuButton = "DocumentDetail.MenuButton"
const val editButton = "DocumentDetail.EditButton"
const val deleteButton = "DocumentDetail.DeleteButton"
const val shareButton = "DocumentDetail.ShareButton"
const val downloadButton = "DocumentDetail.DownloadButton"
}
// MARK: - Onboarding
object Onboarding {
// Welcome Screen
const val welcomeTitle = "Onboarding.WelcomeTitle"
const val startFreshButton = "Onboarding.StartFreshButton"
const val joinExistingButton = "Onboarding.JoinExistingButton"
const val loginButton = "Onboarding.LoginButton"
// Value Props Screen
const val valuePropsTitle = "Onboarding.ValuePropsTitle"
const val valuePropsNextButton = "Onboarding.ValuePropsNextButton"
// Name Residence Screen
const val nameResidenceTitle = "Onboarding.NameResidenceTitle"
const val residenceNameField = "Onboarding.ResidenceNameField"
const val nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton"
// Create Account Screen
const val createAccountTitle = "Onboarding.CreateAccountTitle"
const val appleSignInButton = "Onboarding.AppleSignInButton"
const val emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton"
const val usernameField = "Onboarding.UsernameField"
const val emailField = "Onboarding.EmailField"
const val passwordField = "Onboarding.PasswordField"
const val confirmPasswordField = "Onboarding.ConfirmPasswordField"
const val createAccountButton = "Onboarding.CreateAccountButton"
const val loginLinkButton = "Onboarding.LoginLinkButton"
// Verify Email Screen
const val verifyEmailTitle = "Onboarding.VerifyEmailTitle"
const val verificationCodeField = "Onboarding.VerificationCodeField"
const val verifyButton = "Onboarding.VerifyButton"
// Join Residence Screen
const val joinResidenceTitle = "Onboarding.JoinResidenceTitle"
const val shareCodeField = "Onboarding.ShareCodeField"
const val joinResidenceButton = "Onboarding.JoinResidenceButton"
// First Task Screen
const val firstTaskTitle = "Onboarding.FirstTaskTitle"
const val taskSelectionCounter = "Onboarding.TaskSelectionCounter"
const val addPopularTasksButton = "Onboarding.AddPopularTasksButton"
const val addTasksContinueButton = "Onboarding.AddTasksContinueButton"
const val taskCategorySection = "Onboarding.TaskCategorySection"
const val taskTemplateRow = "Onboarding.TaskTemplateRow"
// Subscription Screen
const val subscriptionTitle = "Onboarding.SubscriptionTitle"
const val yearlyPlanCard = "Onboarding.YearlyPlanCard"
const val monthlyPlanCard = "Onboarding.MonthlyPlanCard"
const val startTrialButton = "Onboarding.StartTrialButton"
const val continueWithFreeButton = "Onboarding.ContinueWithFreeButton"
// Navigation
const val backButton = "Onboarding.BackButton"
const val skipButton = "Onboarding.SkipButton"
const val progressIndicator = "Onboarding.ProgressIndicator"
}
// MARK: - Profile
object Profile {
const val logoutButton = "Profile.LogoutButton"
const val editProfileButton = "Profile.EditProfileButton"
const val settingsButton = "Profile.SettingsButton"
const val notificationsToggle = "Profile.NotificationsToggle"
const val darkModeToggle = "Profile.DarkModeToggle"
const val aboutButton = "Profile.AboutButton"
const val helpButton = "Profile.HelpButton"
}
// MARK: - Alerts & Modals
object Alert {
const val confirmButton = "Alert.ConfirmButton"
const val cancelButton = "Alert.CancelButton"
const val deleteButton = "Alert.DeleteButton"
const val okButton = "Alert.OKButton"
}
// MARK: - Common
object Common {
const val loadingIndicator = "Common.LoadingIndicator"
const val errorView = "Common.ErrorView"
const val retryButton = "Common.RetryButton"
const val searchField = "Common.SearchField"
const val filterButton = "Common.FilterButton"
const val sortButton = "Common.SortButton"
const val refreshControl = "Common.RefreshControl"
}
/**
* Convenience helper to generate dynamic identifiers.
* Example: withId(Residence.residenceCard, residenceId) yields
* Residence.Card.{residenceId}.
*/
fun withId(base: String, id: Any): String = "$base.$id"
}