From 2d80ade6bcd576328c7366a1f0447f32474c9937 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 14:19:37 -0500 Subject: [PATCH] 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) --- .../kotlin/com/tt/honeyDue/AAA_SeedTests.kt | 177 +++++++++++ .../kotlin/com/tt/honeyDue/UITestHelpers.kt | 115 +++++++ .../com/tt/honeyDue/fixtures/TestResidence.kt | 60 ++++ .../com/tt/honeyDue/fixtures/TestTask.kt | 51 ++++ .../com/tt/honeyDue/fixtures/TestUser.kt | 48 +++ .../kotlin/com/tt/honeyDue/ui/BaseScreen.kt | 85 ++++++ .../com/tt/honeyDue/ui/screens/LoginScreen.kt | 64 ++++ .../tt/honeyDue/ui/screens/MainTabScreen.kt | 63 ++++ .../tt/honeyDue/ui/screens/RegisterScreen.kt | 63 ++++ .../com/tt/honeyDue/ui/screens/Screens.kt | 13 + .../tt/honeyDue/testing/AccessibilityIds.kt | 289 ++++++++++++++++++ scripts/verify_test_tag_parity.sh | 48 +++ 12 files changed, 1076 insertions(+) create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/AAA_SeedTests.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/UITestHelpers.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestResidence.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestTask.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestUser.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/BaseScreen.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/LoginScreen.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/MainTabScreen.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/Screens.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/AccessibilityIds.kt create mode 100755 scripts/verify_test_tag_parity.sh diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/AAA_SeedTests.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/AAA_SeedTests.kt new file mode 100644 index 0000000..424739c --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/AAA_SeedTests.kt @@ -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() + 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. + 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 (e: Throwable) { + false + } + } +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/UITestHelpers.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/UITestHelpers.kt new file mode 100644 index 0000000..1760169 --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/UITestHelpers.kt @@ -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. + } + } +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestResidence.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestResidence.kt new file mode 100644 index 0000000..941ab54 --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestResidence.kt @@ -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() + } +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestTask.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestTask.kt new file mode 100644 index 0000000..6f9111f --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestTask.kt @@ -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() + } +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestUser.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestUser.kt new file mode 100644 index 0000000..b5c00c9 --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/TestUser.kt @@ -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() + } +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/BaseScreen.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/BaseScreen.kt new file mode 100644 index 0000000..556931e --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/BaseScreen.kt @@ -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 + } +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/LoginScreen.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..d6145bb --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/LoginScreen.kt @@ -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) +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/MainTabScreen.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/MainTabScreen.kt new file mode 100644 index 0000000..11146ae --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/MainTabScreen.kt @@ -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) +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt new file mode 100644 index 0000000..6cb12f5 --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt @@ -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) +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/Screens.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/Screens.kt new file mode 100644 index 0000000..58339c8 --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/ui/screens/Screens.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/AccessibilityIds.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/AccessibilityIds.kt new file mode 100644 index 0000000..2fa69eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/AccessibilityIds.kt @@ -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" +} diff --git a/scripts/verify_test_tag_parity.sh b/scripts/verify_test_tag_parity.sh new file mode 100755 index 0000000..f8274ef --- /dev/null +++ b/scripts/verify_test_tag_parity.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Verify accessibility identifier parity between iOS and Android test harnesses. +# +# Extracts every string literal from both the Swift and Kotlin ID catalogs +# and fails if any iOS-defined ID is missing from the Kotlin side. Kotlin is +# allowed to be a superset (Android-only test hooks are fine); iOS ⊆ Kotlin +# is the invariant the test suites rely on. +set -euo pipefail + +cd "$(dirname "$0")/.." + +ios_file="iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift" +kotlin_file="composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/AccessibilityIds.kt" + +if [ ! -f "$ios_file" ]; then + echo "ERROR: iOS catalog not found at $ios_file" >&2 + exit 1 +fi +if [ ! -f "$kotlin_file" ]; then + echo "ERROR: Kotlin catalog not found at $kotlin_file" >&2 + exit 1 +fi + +ios_ids=$(mktemp) +kotlin_ids=$(mktemp) +trap 'rm -f "$ios_ids" "$kotlin_ids"' EXIT + +# Extract quoted string literals but ignore any string containing interpolation +# tokens (Swift `\(...)` or Kotlin `$` / `${...}`). Those are illustrative doc +# examples, not real accessibility IDs to compare. +grep -oE '"[^"]+"' "$ios_file" | grep -v '\\(' | sort -u > "$ios_ids" +grep -oE '"[^"]+"' "$kotlin_file" | grep -v '\$' | sort -u > "$kotlin_ids" + +echo "iOS-only IDs (missing in Android):" +comm -23 "$ios_ids" "$kotlin_ids" +echo "" +echo "Android-only IDs (not in iOS):" +comm -13 "$ios_ids" "$kotlin_ids" + +missing=$(comm -23 "$ios_ids" "$kotlin_ids" | wc -l | tr -d ' ') +if [ "$missing" != "0" ]; then + echo "" + echo "FAIL: $missing iOS ID(s) have no Kotlin counterpart." >&2 + exit 1 +fi + +echo "" +echo "OK: all iOS accessibility IDs have Kotlin counterparts."