diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/Suite7_ContractorTests.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/Suite7_ContractorTests.kt new file mode 100644 index 0000000..ba103c5 --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/Suite7_ContractorTests.kt @@ -0,0 +1,446 @@ +package com.tt.honeyDue + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.tt.honeyDue.testing.AccessibilityIds +import com.tt.honeyDue.ui.screens.MainTabScreen +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Comprehensive contractor testing suite — 1:1 port of + * `iosApp/HoneyDueUITests/Suite7_ContractorTests.swift`. + * + * Method names mirror the Swift test cases exactly. The helpers at the + * bottom of the file are localized to this suite rather than added to the + * shared `ui/screens/` page objects so this port stays self-contained and + * doesn't conflict with parallel suite ports (Suite1/4/5). + * + * Uses the real dev backend via the shared `AAA_SeedTests` login. Tests + * track their created contractors and rely on `SuiteZZ_Cleanup` (future) + * plus backend idempotency to avoid poisoning subsequent runs. + */ +@RunWith(AndroidJUnit4::class) +class Suite7_ContractorTests { + + @get:Rule + val rule = createAndroidComposeRule() + + private val createdContractorNames: MutableList = mutableListOf() + + @Before + fun setUp() { + // AAA_SeedTests.a01_seedTestUserCreated guarantees the backend has + // `testuser`; we just have to drive the UI to a logged-in state. + UITestHelpers.ensureOnLoginScreen(rule) + UITestHelpers.loginAsTestUser(rule) + navigateToContractors() + waitForContractorsListReady() + } + + @After + fun tearDown() { + UITestHelpers.tearDown(rule) + createdContractorNames.clear() + } + + // MARK: - 1. Validation & Error Handling Tests + + @Test + fun test01_cannotCreateContractorWithEmptyName() { + openContractorForm() + + // Fill phone but leave name blank — save should stay disabled. + fillField(AccessibilityIds.Contractor.phoneField, "555-123-4567") + + val submit = tag(AccessibilityIds.Contractor.saveButton) + submit.assertExists() + submit.assertIsNotEnabled() + } + + @Test + fun test02_cancelContractorCreation() { + openContractorForm() + + fillField(AccessibilityIds.Contractor.nameField, "This will be canceled") + + val cancel = tag(AccessibilityIds.Contractor.formCancelButton) + cancel.assertExists() + cancel.performClick() + + // Back on contractors tab — add button should be visible again. + waitForTag(AccessibilityIds.Contractor.addButton) + assertFalse( + "Canceled contractor should not exist", + contractorExists("This will be canceled"), + ) + } + + // MARK: - 2. Basic Contractor Creation Tests + + @Test + fun test03_createContractorWithMinimalData() { + val contractorName = "John Doe ${timestamp()}" + createContractor(name = contractorName) + + assertTrue( + "Contractor should appear in list after creation", + waitForContractor(contractorName), + ) + } + + @Test + fun test04_createContractorWithAllFields() { + val contractorName = "Jane Smith ${timestamp()}" + createContractor( + name = contractorName, + email = "jane.smith@example.com", + company = "Smith Plumbing Inc", + ) + + assertTrue( + "Complete contractor should appear in list", + waitForContractor(contractorName), + ) + } + + @Test + fun test05_createContractorWithDifferentSpecialties() { + val ts = timestamp() + val specialties = listOf("Plumbing", "Electrical", "HVAC") + + specialties.forEachIndexed { index, _ -> + val name = "${specialties[index]} Expert ${ts}_$index" + createContractor(name = name) + navigateToContractors() + } + + specialties.forEachIndexed { index, _ -> + navigateToContractors() + val name = "${specialties[index]} Expert ${ts}_$index" + assertTrue( + "${specialties[index]} contractor should exist in list", + waitForContractor(name), + ) + } + } + + @Test + fun test06_createMultipleContractorsInSequence() { + val ts = timestamp() + for (i in 1..3) { + val name = "Sequential Contractor $i - $ts" + createContractor(name = name) + navigateToContractors() + } + for (i in 1..3) { + val name = "Sequential Contractor $i - $ts" + assertTrue("Contractor $i should exist in list", waitForContractor(name)) + } + } + + // MARK: - 3. Edge Case Tests - Phone Numbers + + @Test + fun test07_createContractorWithDifferentPhoneFormats() { + val ts = timestamp() + val phoneFormats = listOf( + "555-123-4567" to "Dashed", + "(555) 123-4567" to "Parentheses", + "5551234567" to "NoFormat", + "555.123.4567" to "Dotted", + ) + phoneFormats.forEachIndexed { index, (phone, format) -> + val name = "$format Phone ${ts}_$index" + createContractor(name = name, phone = phone) + navigateToContractors() + } + phoneFormats.forEachIndexed { index, (_, format) -> + navigateToContractors() + val name = "$format Phone ${ts}_$index" + assertTrue( + "Contractor with $format phone should exist", + waitForContractor(name), + ) + } + } + + // MARK: - 4. Edge Case Tests - Emails + + @Test + fun test08_createContractorWithValidEmails() { + val ts = timestamp() + val emails = listOf( + "simple@example.com", + "firstname.lastname@example.com", + "email+tag@example.co.uk", + "email_with_underscore@example.com", + ) + emails.forEachIndexed { index, email -> + val name = "Email Test $index - $ts" + createContractor(name = name, email = email) + navigateToContractors() + } + } + + // MARK: - 5. Edge Case Tests - Names + + @Test + fun test09_createContractorWithVeryLongName() { + val ts = timestamp() + val longName = + "John Christopher Alexander Montgomery Wellington III Esquire $ts" + createContractor(name = longName) + assertTrue( + "Long name contractor should exist", + waitForContractor("John Christopher"), + ) + } + + @Test + fun test10_createContractorWithSpecialCharactersInName() { + val ts = timestamp() + val specialName = "O'Brien-Smith Jr. $ts" + createContractor(name = specialName) + assertTrue( + "Contractor with special chars should exist", + waitForContractor("O'Brien"), + ) + } + + @Test + fun test11_createContractorWithInternationalCharacters() { + val ts = timestamp() + val internationalName = "Jos\u00e9 Garc\u00eda $ts" + createContractor(name = internationalName) + assertTrue( + "Contractor with international chars should exist", + waitForContractor("Jos\u00e9"), + ) + } + + @Test + fun test12_createContractorWithEmojisInName() { + val ts = timestamp() + val emojiName = "Bob \uD83D\uDD27 Builder $ts" + createContractor(name = emojiName) + assertTrue( + "Contractor with emojis should exist", + waitForContractor("Bob"), + ) + } + + // MARK: - 6. Contractor Editing Tests + + @Test + fun test13_editContractorName() { + val ts = timestamp() + val originalName = "Original Contractor $ts" + val newName = "Edited Contractor $ts" + + createContractor(name = originalName) + navigateToContractors() + + assertTrue( + "Contractor should exist before editing", + waitForContractor(originalName), + ) + rule.onNode(hasText(originalName, substring = true), useUnmergedTree = true) + .performClick() + + // On Android the detail top bar exposes edit directly (no ellipsis + // intermediate), unlike iOS. Tap the edit button to open the dialog. + waitForTag(AccessibilityIds.Contractor.editButton) + tag(AccessibilityIds.Contractor.editButton).performClick() + + waitForTag(AccessibilityIds.Contractor.nameField) + tag(AccessibilityIds.Contractor.nameField).performTextReplacement(newName) + + val save = tag(AccessibilityIds.Contractor.saveButton) + if (existsTag(AccessibilityIds.Contractor.saveButton)) { + save.performClick() + createdContractorNames.add(newName) + } + } + + // test14_updateAllContractorFields — skipped on iOS (multi-field edit + // unreliable with email keyboard type). Skipped here for parity. + + @Test + fun test15_navigateFromContractorsToOtherTabs() { + navigateToContractors() + + // Residences + waitForTag(AccessibilityIds.Navigation.residencesTab) + tag(AccessibilityIds.Navigation.residencesTab).performClick() + waitForTag(AccessibilityIds.Navigation.residencesTab) + + // Back to Contractors + tag(AccessibilityIds.Navigation.contractorsTab).performClick() + waitForTag(AccessibilityIds.Navigation.contractorsTab) + + // Tasks + tag(AccessibilityIds.Navigation.tasksTab).performClick() + waitForTag(AccessibilityIds.Navigation.tasksTab) + + // Back to Contractors + tag(AccessibilityIds.Navigation.contractorsTab).performClick() + waitForTag(AccessibilityIds.Navigation.contractorsTab) + } + + @Test + fun test16_refreshContractorsList() { + navigateToContractors() + // Refresh button is not explicitly exposed on Android contractors + // screen; we exercise pull-to-refresh indirectly by re-navigating. + waitForTag(AccessibilityIds.Navigation.contractorsTab) + tag(AccessibilityIds.Navigation.contractorsTab).performClick() + waitForContractorsListReady() + assertTrue( + "Add button should remain visible after refresh", + existsTag(AccessibilityIds.Contractor.addButton), + ) + } + + @Test + fun test17_viewContractorDetails() { + val ts = timestamp() + val contractorName = "Detail View Test $ts" + createContractor( + name = contractorName, + email = "test@example.com", + company = "Test Company", + ) + navigateToContractors() + assertTrue("Contractor should exist", waitForContractor(contractorName)) + + rule.onNode(hasText(contractorName, substring = true), useUnmergedTree = true) + .performClick() + + // Detail view should load and show at least one contact field. + waitForTag(AccessibilityIds.Contractor.detailView, timeoutMs = 10_000L) + tag(AccessibilityIds.Contractor.detailView).assertIsDisplayed() + } + + // MARK: - 8. Data Persistence Tests + + @Test + fun test18_contractorPersistsAfterBackgroundingApp() { + val ts = timestamp() + val contractorName = "Persistence Test $ts" + createContractor(name = contractorName) + navigateToContractors() + assertTrue( + "Contractor should exist before backgrounding", + waitForContractor(contractorName), + ) + + // Backgrounding an Activity from the ComposeTestRule is brittle; + // exercise the recompose path instead by re-navigating, matching the + // intent of the iOS test (state survives a scroll/rebind cycle). + tag(AccessibilityIds.Navigation.tasksTab).performClick() + waitForTag(AccessibilityIds.Navigation.tasksTab) + tag(AccessibilityIds.Navigation.contractorsTab).performClick() + waitForContractorsListReady() + + assertTrue( + "Contractor should persist after tab cycle", + waitForContractor(contractorName), + ) + } + + // ---- Helpers ---- + + private fun timestamp(): Long = System.currentTimeMillis() / 1000 + + private fun tag(testTag: String): SemanticsNodeInteraction = + rule.onNodeWithTag(testTag, useUnmergedTree = true) + + private fun existsTag(testTag: String): Boolean = try { + tag(testTag).assertExists() + true + } catch (e: AssertionError) { + false + } + + private fun waitForTag(testTag: String, timeoutMs: Long = 10_000L) { + rule.waitUntil(timeoutMs) { existsTag(testTag) } + } + + private fun fillField(testTag: String, text: String) { + waitForTag(testTag) + tag(testTag).performTextInput(text) + } + + private fun navigateToContractors() { + waitForTag(AccessibilityIds.Navigation.contractorsTab) + tag(AccessibilityIds.Navigation.contractorsTab).performClick() + } + + private fun waitForContractorsListReady(timeoutMs: Long = 15_000L) { + rule.waitUntil(timeoutMs) { + existsTag(AccessibilityIds.Contractor.addButton) || + existsTag(AccessibilityIds.Contractor.contractorsList) || + existsTag(AccessibilityIds.Contractor.emptyStateView) + } + } + + private fun openContractorForm() { + waitForTag(AccessibilityIds.Contractor.addButton) + tag(AccessibilityIds.Contractor.addButton).performClick() + waitForTag(AccessibilityIds.Contractor.nameField) + } + + private fun contractorExists(name: String): Boolean = try { + rule.onNode(hasText(name, substring = true), useUnmergedTree = true).assertExists() + true + } catch (e: AssertionError) { + false + } + + private fun waitForContractor(name: String, timeoutMs: Long = 10_000L): Boolean = try { + rule.waitUntil(timeoutMs) { contractorExists(name) } + true + } catch (e: Throwable) { + false + } + + private fun createContractor( + name: String, + phone: String? = null, + email: String? = null, + company: String? = null, + ) { + openContractorForm() + + fillField(AccessibilityIds.Contractor.nameField, name) + + phone?.let { fillField(AccessibilityIds.Contractor.phoneField, it) } + email?.let { fillField(AccessibilityIds.Contractor.emailField, it) } + company?.let { fillField(AccessibilityIds.Contractor.companyField, it) } + + waitForTag(AccessibilityIds.Contractor.saveButton) + tag(AccessibilityIds.Contractor.saveButton).performClick() + + // Dialog dismisses on success — wait for the add button to be + // interactable again (signals form closed and list refreshed). + rule.waitUntil(15_000L) { + !existsTag(AccessibilityIds.Contractor.nameField) && + existsTag(AccessibilityIds.Contractor.addButton) + } + createdContractorNames.add(name) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AddContractorDialog.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AddContractorDialog.kt index 206d1f8..c0dccb2 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AddContractorDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AddContractorDialog.kt @@ -10,7 +10,9 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import com.tt.honeyDue.testing.AccessibilityIds import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import honeydue.composeapp.generated.resources.* @@ -147,7 +149,9 @@ fun AddContractorDialog( value = name, onValueChange = { name = it }, label = { Text(stringResource(Res.string.contractors_form_name_required)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(AccessibilityIds.Contractor.nameField), singleLine = true, shape = RoundedCornerShape(12.dp), leadingIcon = { Icon(Icons.Default.Person, null) }, @@ -161,7 +165,9 @@ fun AddContractorDialog( value = company, onValueChange = { company = it }, label = { Text(stringResource(Res.string.contractors_form_company)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(AccessibilityIds.Contractor.companyField), singleLine = true, shape = RoundedCornerShape(12.dp), leadingIcon = { Icon(Icons.Default.Business, null) }, @@ -243,7 +249,9 @@ fun AddContractorDialog( value = phone, onValueChange = { phone = it }, label = { Text(stringResource(Res.string.contractors_form_phone)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(AccessibilityIds.Contractor.phoneField), singleLine = true, shape = RoundedCornerShape(12.dp), leadingIcon = { Icon(Icons.Default.Phone, null) }, @@ -257,7 +265,9 @@ fun AddContractorDialog( value = email, onValueChange = { email = it }, label = { Text(stringResource(Res.string.contractors_form_email)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(AccessibilityIds.Contractor.emailField), singleLine = true, shape = RoundedCornerShape(12.dp), leadingIcon = { Icon(Icons.Default.Email, null) }, @@ -293,6 +303,7 @@ fun AddContractorDialog( // Multi-select specialties using chips FlowRow( + modifier = Modifier.testTag(AccessibilityIds.Contractor.specialtyPicker), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -396,7 +407,8 @@ fun AddContractorDialog( label = { Text(stringResource(Res.string.contractors_form_private_notes)) }, modifier = Modifier .fillMaxWidth() - .height(100.dp), + .height(100.dp) + .testTag(AccessibilityIds.Contractor.notesField), maxLines = 4, shape = RoundedCornerShape(12.dp), leadingIcon = { Icon(Icons.Default.Notes, null) }, @@ -491,7 +503,8 @@ fun AddContractorDialog( createState !is ApiResult.Loading && updateState !is ApiResult.Loading, colors = ButtonDefaults.buttonColors( containerColor = Color(0xFF2563EB) - ) + ), + modifier = Modifier.testTag(AccessibilityIds.Contractor.saveButton) ) { if (createState is ApiResult.Loading || updateState is ApiResult.Loading) { CircularProgressIndicator( @@ -505,7 +518,10 @@ fun AddContractorDialog( } }, dismissButton = { - TextButton(onClick = onDismiss) { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(AccessibilityIds.Contractor.formCancelButton) + ) { Text(cancelText, color = Color(0xFF6B7280)) } }, diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ContractorDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ContractorDetailScreen.kt index b7da48b..ee2c137 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ContractorDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ContractorDetailScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import com.tt.honeyDue.testing.AccessibilityIds import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -93,15 +95,18 @@ fun ContractorDetailScreen( actions = { when (val state = contractorState) { is ApiResult.Success -> { - IconButton(onClick = { - val shareCheck = SubscriptionHelper.canShareContractor() - if (shareCheck.allowed) { - shareContractor(state.data) - } else { - upgradeTriggerKey = shareCheck.triggerKey - showUpgradePrompt = true - } - }) { + IconButton( + onClick = { + val shareCheck = SubscriptionHelper.canShareContractor() + if (shareCheck.allowed) { + shareContractor(state.data) + } else { + upgradeTriggerKey = shareCheck.triggerKey + showUpgradePrompt = true + } + }, + modifier = Modifier.testTag(AccessibilityIds.Contractor.shareButton) + ) { Icon(Icons.Default.Share, stringResource(Res.string.common_share)) } IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) { @@ -111,10 +116,16 @@ fun ContractorDetailScreen( tint = if (state.data.isFavorite) Color(0xFFF59E0B) else LocalContentColor.current ) } - IconButton(onClick = { showEditDialog = true }) { + IconButton( + onClick = { showEditDialog = true }, + modifier = Modifier.testTag(AccessibilityIds.Contractor.editButton) + ) { Icon(Icons.Default.Edit, stringResource(Res.string.common_edit)) } - IconButton(onClick = { showDeleteConfirmation = true }) { + IconButton( + onClick = { showDeleteConfirmation = true }, + modifier = Modifier.testTag(AccessibilityIds.Contractor.deleteButton) + ) { Icon(Icons.Default.Delete, stringResource(Res.string.common_delete), tint = Color(0xFFEF4444)) } } @@ -132,6 +143,7 @@ fun ContractorDetailScreen( modifier = Modifier .fillMaxSize() .padding(padding) + .testTag(AccessibilityIds.Contractor.detailView) ) { val uriHandler = LocalUriHandler.current val residences = DataManager.residences.value @@ -263,7 +275,9 @@ fun ContractorDetailScreen( icon = Icons.Default.Phone, label = stringResource(Res.string.contractors_call), color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag(AccessibilityIds.Contractor.callButton), onClick = { try { uriHandler.openUri("tel:${phone.replace(" ", "")}") @@ -277,7 +291,9 @@ fun ContractorDetailScreen( icon = Icons.Default.Email, label = stringResource(Res.string.contractors_send_email), color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag(AccessibilityIds.Contractor.emailButton), onClick = { try { uriHandler.openUri("mailto:$email") diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ContractorsScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ContractorsScreen.kt index f1934da..5aaea53 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ContractorsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ContractorsScreen.kt @@ -11,7 +11,9 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import com.tt.honeyDue.testing.AccessibilityIds import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -190,7 +192,8 @@ fun ContractorsScreen( } }, containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.testTag(AccessibilityIds.Contractor.addButton) ) { Icon(Icons.Default.Add, stringResource(Res.string.contractors_add_button)) } @@ -288,7 +291,9 @@ fun ContractorsScreen( if (filteredContractors.isEmpty()) { // Empty state with organic styling Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .testTag(AccessibilityIds.Contractor.emptyStateView), contentAlignment = Alignment.Center ) { Column( @@ -331,7 +336,9 @@ fun ContractorsScreen( modifier = Modifier.fillMaxSize() ) { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .testTag(AccessibilityIds.Contractor.contractorsList), contentPadding = PaddingValues(OrganicSpacing.medium), verticalArrangement = Arrangement.spacedBy(OrganicSpacing.medium) ) { @@ -387,6 +394,7 @@ fun ContractorCard( OrganicCard( modifier = Modifier .fillMaxWidth() + .testTag(AccessibilityIds.withId(AccessibilityIds.Contractor.contractorCard, contractor.id)) .clickable { onClick(contractor.id) } ) { Row(