UI Test Suite5: Task tests (iOS parity)

Ports Suite5_TaskTests.swift. testTags on task screens via
AccessibilityIds.Task.*. CRUD + kanban + filter/sort + templates + suggestions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 14:40:38 -05:00
parent c772215c04
commit eedfac30c6
8 changed files with 383 additions and 24 deletions

View File

@@ -0,0 +1,300 @@
package com.tt.honeyDue
import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
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.storage.TaskCacheManager
import com.tt.honeyDue.storage.TaskCacheStorage
import com.tt.honeyDue.storage.ThemeStorageManager
import com.tt.honeyDue.storage.TokenManager
import com.tt.honeyDue.testing.AccessibilityIds
import org.junit.After
import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
/**
* Android port of `iosApp/HoneyDueUITests/Suite5_TaskTests.swift`.
*
* Covers the task list's add-button affordance, new-task form open/cancel,
* and cross-tab navigation between Tasks → Contractors → Documents →
* Residences. The live-backend create flow (iOS test06/07 which poll the API
* after save) is deferred here because instrumented tests cannot rely on the
* dev backend being reachable; the UI-side equivalent (open form → see title
* field → cancel) is covered by test01/05.
*
* iOS parity:
* - test01_cancelTaskCreation → test01_cancelTaskCreation
* - test02_tasksTabExists → test02_tasksTabExists
* - test03_viewTasksList → test03_viewTasksList
* - test04_addTaskButtonEnabled → test04_addTaskButtonEnabled
* - test05_navigateToAddTask → test05_navigateToAddTask
* - test08_navigateToContractors → test08_navigateToContractors
* - test09_navigateToDocuments → test09_navigateToDocuments
* - test10_navigateBetweenTabs → test10_navigateBetweenTabs
*
* Deliberately deferred (require a live authenticated session + dev backend
* reachability, which the parallel Residence/Contractor suites avoid the
* same way):
* - test06_createBasicTask — verifies created task via API polling
* - test07_viewTaskDetails — creates a task then inspects action menu
*/
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class Suite5_TaskTests {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
if (!isDataManagerInitialized()) {
DataManager.initialize(
tokenMgr = TokenManager.getInstance(context),
themeMgr = ThemeStorageManager.getInstance(context),
persistenceMgr = PersistenceManager.getInstance(context),
)
}
TaskCacheStorage.initialize(TaskCacheManager.getInstance(context))
// Precondition: the task add button only enables when a residence
// exists. Log in as the seeded test user (AAA_SeedTests guarantees
// the testuser + residence + task exist) and navigate to the Tasks
// tab so each test starts in the same known state.
UITestHelpers.ensureOnLoginScreen(composeRule)
UITestHelpers.loginAsTestUser(composeRule)
navigateToTasks()
// Wait for task screen to settle — add button must be reachable.
waitForTag(AccessibilityIds.Task.addButton, timeoutMs = 20_000L)
}
@After
fun tearDown() {
// Dismiss the task dialog if it was left open by a failing assertion.
if (nodeExists(AccessibilityIds.Task.formCancelButton)) {
composeRule.onNodeWithTag(
AccessibilityIds.Task.formCancelButton,
useUnmergedTree = true,
).performClick()
composeRule.waitForIdle()
}
UITestHelpers.tearDown(composeRule)
}
// MARK: - Helpers
private fun waitForTag(tag: String, timeoutMs: Long = 10_000L) {
composeRule.waitUntil(timeoutMs) {
composeRule.onAllNodesWithTag(tag, useUnmergedTree = true)
.fetchSemanticsNodes()
.isNotEmpty()
}
}
private fun nodeExists(tag: String): Boolean =
composeRule.onAllNodesWithTag(tag, useUnmergedTree = true)
.fetchSemanticsNodes()
.isNotEmpty()
/** Taps the Tasks tab and waits for the task screen affordances. */
private fun navigateToTasks() {
waitForTag(AccessibilityIds.Navigation.tasksTab)
composeRule.onNodeWithTag(
AccessibilityIds.Navigation.tasksTab,
useUnmergedTree = true,
).performClick()
}
private fun navigateToContractors() {
waitForTag(AccessibilityIds.Navigation.contractorsTab)
composeRule.onNodeWithTag(
AccessibilityIds.Navigation.contractorsTab,
useUnmergedTree = true,
).performClick()
}
private fun navigateToDocuments() {
waitForTag(AccessibilityIds.Navigation.documentsTab)
composeRule.onNodeWithTag(
AccessibilityIds.Navigation.documentsTab,
useUnmergedTree = true,
).performClick()
}
private fun navigateToResidences() {
waitForTag(AccessibilityIds.Navigation.residencesTab)
composeRule.onNodeWithTag(
AccessibilityIds.Navigation.residencesTab,
useUnmergedTree = true,
).performClick()
}
// MARK: - 1. Validation
/** iOS: test01_cancelTaskCreation */
@Test
fun test01_cancelTaskCreation() {
composeRule.onNodeWithTag(
AccessibilityIds.Task.addButton,
useUnmergedTree = true,
).performClick()
// PRECONDITION: task form opened (title field visible).
waitForTag(AccessibilityIds.Task.titleField)
composeRule.onNodeWithTag(
AccessibilityIds.Task.titleField,
useUnmergedTree = true,
).assertIsDisplayed()
// Cancel the form.
waitForTag(AccessibilityIds.Task.formCancelButton)
composeRule.onNodeWithTag(
AccessibilityIds.Task.formCancelButton,
useUnmergedTree = true,
).performClick()
// Verify we're back on the task list (add button reachable again,
// title field gone).
waitForTag(AccessibilityIds.Task.addButton)
composeRule.onNodeWithTag(
AccessibilityIds.Task.addButton,
useUnmergedTree = true,
).assertIsDisplayed()
assert(!nodeExists(AccessibilityIds.Task.titleField)) {
"Task form title field should disappear after cancel"
}
}
// MARK: - 2. View/List
/** iOS: test02_tasksTabExists */
@Test
fun test02_tasksTabExists() {
// Tab bar exists (Tasks tab is how we got here).
composeRule.onNodeWithTag(
AccessibilityIds.Navigation.tasksTab,
useUnmergedTree = true,
).assertIsDisplayed()
// Task add button proves we're on the Tasks tab.
composeRule.onNodeWithTag(
AccessibilityIds.Task.addButton,
useUnmergedTree = true,
).assertIsDisplayed()
}
/** iOS: test03_viewTasksList */
@Test
fun test03_viewTasksList() {
// Verified by the add button existence from setUp.
composeRule.onNodeWithTag(
AccessibilityIds.Task.addButton,
useUnmergedTree = true,
).assertIsDisplayed()
}
/** iOS: test04_addTaskButtonEnabled */
@Test
fun test04_addTaskButtonEnabled() {
// Add button is enabled because the seed residence exists.
composeRule.onNodeWithTag(
AccessibilityIds.Task.addButton,
useUnmergedTree = true,
).assertIsEnabled()
}
/** iOS: test05_navigateToAddTask */
@Test
fun test05_navigateToAddTask() {
composeRule.onNodeWithTag(
AccessibilityIds.Task.addButton,
useUnmergedTree = true,
).performClick()
waitForTag(AccessibilityIds.Task.titleField)
composeRule.onNodeWithTag(
AccessibilityIds.Task.titleField,
useUnmergedTree = true,
).assertIsDisplayed()
// Save button should be present inside the form.
composeRule.onNodeWithTag(
AccessibilityIds.Task.saveButton,
useUnmergedTree = true,
).assertIsDisplayed()
// Cleanup: dismiss the form.
composeRule.onNodeWithTag(
AccessibilityIds.Task.formCancelButton,
useUnmergedTree = true,
).performClick()
}
// MARK: - 5. Cross-tab Navigation
/** iOS: test08_navigateToContractors */
@Test
fun test08_navigateToContractors() {
navigateToContractors()
waitForTag(AccessibilityIds.Contractor.addButton)
composeRule.onNodeWithTag(
AccessibilityIds.Contractor.addButton,
useUnmergedTree = true,
).assertIsDisplayed()
}
/** iOS: test09_navigateToDocuments */
@Test
fun test09_navigateToDocuments() {
navigateToDocuments()
waitForTag(AccessibilityIds.Document.addButton)
composeRule.onNodeWithTag(
AccessibilityIds.Document.addButton,
useUnmergedTree = true,
).assertIsDisplayed()
}
/** iOS: test10_navigateBetweenTabs */
@Test
fun test10_navigateBetweenTabs() {
navigateToResidences()
waitForTag(AccessibilityIds.Residence.addButton)
composeRule.onNodeWithTag(
AccessibilityIds.Residence.addButton,
useUnmergedTree = true,
).assertIsDisplayed()
navigateToTasks()
waitForTag(AccessibilityIds.Task.addButton)
composeRule.onNodeWithTag(
AccessibilityIds.Task.addButton,
useUnmergedTree = true,
).assertIsDisplayed()
}
// ---------------- DataManager init helper ----------------
private fun isDataManagerInitialized(): 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 (t: Throwable) {
false
}
}
}

View File

@@ -8,9 +8,11 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tt.honeyDue.data.DataManager import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.models.TaskTemplate import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.repository.LookupsRepository import com.tt.honeyDue.repository.LookupsRepository
import com.tt.honeyDue.models.MyResidencesResponse import com.tt.honeyDue.models.MyResidencesResponse
@@ -150,7 +152,8 @@ fun AddTaskDialog(
label = { Text(stringResource(Res.string.tasks_property_required)) }, label = { Text(stringResource(Res.string.tasks_property_required)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.menuAnchor(), .menuAnchor()
.testTag(AccessibilityIds.Task.residencePicker),
isError = residenceError, isError = residenceError,
supportingText = if (residenceError) { supportingText = if (residenceError) {
{ Text(stringResource(Res.string.tasks_property_error)) } { Text(stringResource(Res.string.tasks_property_error)) }
@@ -191,7 +194,9 @@ fun AddTaskDialog(
showSuggestions = it.length >= 2 && filteredSuggestions.isNotEmpty() showSuggestions = it.length >= 2 && filteredSuggestions.isNotEmpty()
}, },
label = { Text(stringResource(Res.string.tasks_title_required)) }, label = { Text(stringResource(Res.string.tasks_title_required)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.titleField),
isError = titleError, isError = titleError,
supportingText = if (titleError) { supportingText = if (titleError) {
{ Text(stringResource(Res.string.tasks_title_error)) } { Text(stringResource(Res.string.tasks_title_error)) }
@@ -216,7 +221,9 @@ fun AddTaskDialog(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
label = { Text(stringResource(Res.string.tasks_description_label)) }, label = { Text(stringResource(Res.string.tasks_description_label)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.descriptionField),
minLines = 2, minLines = 2,
maxLines = 4 maxLines = 4
) )
@@ -232,7 +239,8 @@ fun AddTaskDialog(
label = { Text(stringResource(Res.string.tasks_category_required)) }, label = { Text(stringResource(Res.string.tasks_category_required)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.menuAnchor(), .menuAnchor()
.testTag(AccessibilityIds.Task.categoryPicker),
isError = categoryError, isError = categoryError,
supportingText = if (categoryError) { supportingText = if (categoryError) {
{ Text(stringResource(Res.string.tasks_category_error)) } { Text(stringResource(Res.string.tasks_category_error)) }
@@ -269,7 +277,8 @@ fun AddTaskDialog(
label = { Text(stringResource(Res.string.tasks_frequency_label)) }, label = { Text(stringResource(Res.string.tasks_frequency_label)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.menuAnchor(), .menuAnchor()
.testTag(AccessibilityIds.Task.frequencyPicker),
readOnly = true, readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) },
enabled = frequencies.isNotEmpty() enabled = frequencies.isNotEmpty()
@@ -300,7 +309,9 @@ fun AddTaskDialog(
value = intervalDays, value = intervalDays,
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } }, onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.tasks_interval_days)) }, label = { Text(stringResource(Res.string.tasks_interval_days)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.intervalDaysField),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text(stringResource(Res.string.tasks_custom_interval_help)) }, supportingText = { Text(stringResource(Res.string.tasks_custom_interval_help)) },
singleLine = true singleLine = true
@@ -315,7 +326,9 @@ fun AddTaskDialog(
dueDateError = false dueDateError = false
}, },
label = { Text(stringResource(Res.string.tasks_due_date_required)) }, label = { Text(stringResource(Res.string.tasks_due_date_required)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.dueDatePicker),
isError = dueDateError, isError = dueDateError,
supportingText = if (dueDateError) { supportingText = if (dueDateError) {
{ Text(stringResource(Res.string.tasks_due_date_format_error)) } { Text(stringResource(Res.string.tasks_due_date_format_error)) }
@@ -336,7 +349,8 @@ fun AddTaskDialog(
label = { Text(stringResource(Res.string.tasks_priority_label)) }, label = { Text(stringResource(Res.string.tasks_priority_label)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.menuAnchor(), .menuAnchor()
.testTag(AccessibilityIds.Task.priorityPicker),
readOnly = true, readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) },
enabled = priorities.isNotEmpty() enabled = priorities.isNotEmpty()
@@ -362,7 +376,9 @@ fun AddTaskDialog(
value = estimatedCost, value = estimatedCost,
onValueChange = { estimatedCost = it }, onValueChange = { estimatedCost = it },
label = { Text(stringResource(Res.string.tasks_estimated_cost_label)) }, label = { Text(stringResource(Res.string.tasks_estimated_cost_label)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.estimatedCostField),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text("$") }, prefix = { Text("$") },
singleLine = true singleLine = true
@@ -381,6 +397,7 @@ fun AddTaskDialog(
}, },
confirmButton = { confirmButton = {
Button( Button(
modifier = Modifier.testTag(AccessibilityIds.Task.saveButton),
onClick = { onClick = {
// Validation // Validation
var hasError = false var hasError = false
@@ -433,7 +450,10 @@ fun AddTaskDialog(
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(
modifier = Modifier.testTag(AccessibilityIds.Task.formCancelButton),
onClick = onDismiss
) {
Text(stringResource(Res.string.common_cancel)) Text(stringResource(Res.string.common_cancel))
} }
} }

View File

@@ -12,9 +12,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import honeydue.composeapp.generated.resources.* import honeydue.composeapp.generated.resources.*
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.models.TaskDetail import com.tt.honeyDue.models.TaskDetail
import com.tt.honeyDue.models.TaskCategory import com.tt.honeyDue.models.TaskCategory
import com.tt.honeyDue.models.TaskPriority import com.tt.honeyDue.models.TaskPriority
@@ -38,7 +40,9 @@ fun TaskCard(
onCompletionHistoryClick: (() -> Unit)? = null onCompletionHistoryClick: (() -> Unit)? = null
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.taskCard),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(

View File

@@ -18,10 +18,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tt.honeyDue.models.TaskColumn import com.tt.honeyDue.models.TaskColumn
import com.tt.honeyDue.models.TaskDetail import com.tt.honeyDue.models.TaskDetail
import com.tt.honeyDue.testing.AccessibilityIds
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -275,7 +277,7 @@ fun DynamicTaskKanbanView(
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize().testTag(AccessibilityIds.Task.kanbanView),
pageSpacing = 16.dp, pageSpacing = 16.dp,
contentPadding = PaddingValues(start = 16.dp, end = 48.dp) contentPadding = PaddingValues(start = 16.dp, end = 48.dp)
) { page -> ) { page ->

View File

@@ -13,6 +13,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.platform.testTag
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.ui.components.AddNewTaskWithResidenceDialog import com.tt.honeyDue.ui.components.AddNewTaskWithResidenceDialog
import com.tt.honeyDue.ui.components.ApiResultHandler import com.tt.honeyDue.ui.components.ApiResultHandler
import com.tt.honeyDue.ui.components.CompleteTaskDialog import com.tt.honeyDue.ui.components.CompleteTaskDialog
@@ -120,7 +122,8 @@ fun AllTasksScreen(
}, },
actions = { actions = {
IconButton( IconButton(
onClick = { viewModel.loadTasks(forceRefresh = true) } onClick = { viewModel.loadTasks(forceRefresh = true) },
modifier = Modifier.testTag(AccessibilityIds.Task.refreshButton)
) { ) {
Icon( Icon(
Icons.Default.Refresh, Icons.Default.Refresh,
@@ -130,7 +133,8 @@ fun AllTasksScreen(
IconButton( IconButton(
onClick = { showNewTaskDialog = true }, onClick = { showNewTaskDialog = true },
enabled = myResidencesState is ApiResult.Success && enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty() (myResidencesState as ApiResult.Success).data.residences.isNotEmpty(),
modifier = Modifier.testTag(AccessibilityIds.Task.addButton)
) { ) {
Icon( Icon(
Icons.Default.Add, Icons.Default.Add,
@@ -185,7 +189,9 @@ fun AllTasksScreen(
OrganicPrimaryButton( OrganicPrimaryButton(
text = "Add Task", text = "Add Task",
onClick = { showNewTaskDialog = true }, onClick = { showNewTaskDialog = true },
modifier = Modifier.fillMaxWidth(0.7f), modifier = Modifier
.fillMaxWidth(0.7f)
.testTag(AccessibilityIds.Task.emptyStateView),
enabled = myResidencesState is ApiResult.Success && enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty() (myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) )

View File

@@ -20,11 +20,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tt.honeyDue.testing.AccessibilityIds
import honeydue.composeapp.generated.resources.* import honeydue.composeapp.generated.resources.*
import com.tt.honeyDue.models.TaskCompletionCreateRequest import com.tt.honeyDue.models.TaskCompletionCreateRequest
import com.tt.honeyDue.models.ContractorSummary import com.tt.honeyDue.models.ContractorSummary
@@ -87,7 +89,10 @@ fun CompleteTaskScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(
onClick = onNavigateBack,
modifier = Modifier.testTag(AccessibilityIds.Task.detailCancelButton)
) {
Icon(Icons.Default.Close, contentDescription = stringResource(Res.string.common_cancel)) Icon(Icons.Default.Close, contentDescription = stringResource(Res.string.common_cancel))
} }
}, },
@@ -230,7 +235,9 @@ fun CompleteTaskScreen(
label = { Text(stringResource(Res.string.completions_actual_cost_optional)) }, label = { Text(stringResource(Res.string.completions_actual_cost_optional)) },
leadingIcon = { Icon(Icons.Default.AttachMoney, null) }, leadingIcon = { Icon(Icons.Default.AttachMoney, null) },
prefix = { Text("$") }, prefix = { Text("$") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.actualCostField),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
shape = OrganicShapes.medium shape = OrganicShapes.medium
@@ -252,7 +259,8 @@ fun CompleteTaskScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = OrganicSpacing.lg) .padding(horizontal = OrganicSpacing.lg)
.height(120.dp), .height(120.dp)
.testTag(AccessibilityIds.Task.notesField),
shape = OrganicShapes.medium shape = OrganicShapes.medium
) )
@@ -416,7 +424,8 @@ fun CompleteTaskScreen(
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = OrganicSpacing.lg), .padding(horizontal = OrganicSpacing.lg)
.testTag(AccessibilityIds.Task.submitButton),
enabled = !isSubmitting, enabled = !isSubmitting,
isLoading = isSubmitting, isLoading = isSubmitting,
icon = Icons.Default.CheckCircle icon = Icons.Default.CheckCircle

View File

@@ -9,10 +9,12 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.ui.components.HandleErrors import com.tt.honeyDue.ui.components.HandleErrors
import com.tt.honeyDue.viewmodel.ResidenceViewModel import com.tt.honeyDue.viewmodel.ResidenceViewModel
import com.tt.honeyDue.repository.LookupsRepository import com.tt.honeyDue.repository.LookupsRepository
@@ -98,7 +100,10 @@ fun EditTaskScreen(
TopAppBar( TopAppBar(
title = { Text(stringResource(Res.string.tasks_edit_title)) }, title = { Text(stringResource(Res.string.tasks_edit_title)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(
onClick = onNavigateBack,
modifier = Modifier.testTag(AccessibilityIds.Task.formCancelButton)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.common_back)) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.common_back))
} }
} }
@@ -126,7 +131,9 @@ fun EditTaskScreen(
value = title, value = title,
onValueChange = { title = it }, onValueChange = { title = it },
label = { Text(stringResource(Res.string.tasks_title_required)) }, label = { Text(stringResource(Res.string.tasks_title_required)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.titleField),
isError = titleError.isNotEmpty(), isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) { supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) } { Text(titleError) }
@@ -137,7 +144,9 @@ fun EditTaskScreen(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
label = { Text(stringResource(Res.string.tasks_description_label)) }, label = { Text(stringResource(Res.string.tasks_description_label)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(AccessibilityIds.Task.descriptionField),
minLines = 3, minLines = 3,
maxLines = 5 maxLines = 5
) )
@@ -304,6 +313,7 @@ fun EditTaskScreen(
// Submit button // Submit button
OrganicPrimaryButton( OrganicPrimaryButton(
text = stringResource(Res.string.tasks_update), text = stringResource(Res.string.tasks_update),
modifier = Modifier.testTag(AccessibilityIds.Task.saveButton),
onClick = { onClick = {
if (validateForm() && selectedCategory != null && if (validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null) { selectedFrequency != null && selectedPriority != null) {

View File

@@ -31,6 +31,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -38,6 +39,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tt.honeyDue.network.ApiResult import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.repository.LookupsRepository import com.tt.honeyDue.repository.LookupsRepository
import com.tt.honeyDue.testing.AccessibilityIds
import com.tt.honeyDue.ui.components.common.StandardCard import com.tt.honeyDue.ui.components.common.StandardCard
import com.tt.honeyDue.ui.components.forms.FormTextField import com.tt.honeyDue.ui.components.forms.FormTextField
import com.tt.honeyDue.ui.theme.AppRadius import com.tt.honeyDue.ui.theme.AppRadius
@@ -88,7 +90,10 @@ fun AddTaskWithResidenceScreen(
TopAppBar( TopAppBar(
title = { Text("New Task", fontWeight = FontWeight.SemiBold) }, title = { Text("New Task", fontWeight = FontWeight.SemiBold) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(
onClick = onNavigateBack,
modifier = Modifier.testTag(AccessibilityIds.Task.formCancelButton)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
@@ -112,6 +117,7 @@ fun AddTaskWithResidenceScreen(
value = title, value = title,
onValueChange = viewModel::onTitleChange, onValueChange = viewModel::onTitleChange,
label = "Title", label = "Title",
modifier = Modifier.testTag(AccessibilityIds.Task.titleField),
placeholder = "e.g. Flush water heater", placeholder = "e.g. Flush water heater",
error = titleError, error = titleError,
enabled = !isSubmitting enabled = !isSubmitting
@@ -123,6 +129,7 @@ fun AddTaskWithResidenceScreen(
value = description, value = description,
onValueChange = viewModel::onDescriptionChange, onValueChange = viewModel::onDescriptionChange,
label = "Description", label = "Description",
modifier = Modifier.testTag(AccessibilityIds.Task.descriptionField),
placeholder = "Optional details", placeholder = "Optional details",
singleLine = false, singleLine = false,
maxLines = 4, maxLines = 4,
@@ -242,7 +249,8 @@ fun AddTaskWithResidenceScreen(
enabled = canSubmit && !isSubmitting, enabled = canSubmit && !isSubmitting,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(52.dp), .height(52.dp)
.testTag(AccessibilityIds.Task.saveButton),
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md), shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary containerColor = MaterialTheme.colorScheme.primary