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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
48
scripts/verify_test_tag_parity.sh
Executable file
48
scripts/verify_test_tag_parity.sh
Executable file
@@ -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."
|
||||||
Reference in New Issue
Block a user