Files
honeyDueKMP/iosApp/HoneyDueUITests/Tests/TaskIntegrationTests.swift
Trey T a4d66c6ed1 Stabilize UI test suite — 39% → 98%+ pass rate
Fix root causes uncovered across repeated parallel runs:

- Admin seed password "test1234" failed backend complexity (needs
  uppercase). Bumped to "Test1234" across every hard-coded reference
  (AuthenticatedUITestCase default, TestAccountManager seeded-login
  default, Tests/*Integration suites, Tests/DataLayer, OnboardingTests).

- dismissKeyboard() tapped the Return key first, which races SwiftUI's
  TextField binding on numeric keyboards (postal, year built) and
  complex forms. KeyboardDismisser now prefers the keyboard-toolbar
  Done button, falls back to tap-above-keyboard, then keyboard Return.
  BaseUITestCase.clearAndEnterText uses the same helper.

- Form page-object save() helpers (task / residence / contractor /
  document) now dismiss the keyboard and scroll the submit button
  into view before tapping, eliminating Suite4/6/7/8 "save button
  stayed visible" timeouts.

- Suite6 createTask was producing a disabled-save race: under
  parallel contention the SwiftUI title binding lagged behind
  XCUITest typing. Rewritten to inline Suite5's proven pattern with
  a retry that nudges the title binding via a no-op edit when Add is
  disabled, and an explicit refreshTasks after creation.

- Suite8 selectProperty now picks the residence by name (works with
  menu, list, or wheel picker variants) — avoids bad form-cell taps
  when the picker hasn't fully rendered.

- run_ui_tests.sh uses 2 workers instead of 4 (4-worker contention
  caused XCUITest typing races across Suite5/7/8) and isolates Suite6
  in its own 2-worker phase after the main parallel phase.

- Add AAA_SeedTests / SuiteZZ_CleanupTests: the runner's Phase 1
  (seed) and Phase 3 (cleanup) depend on these and they were missing
  from version control.
2026-04-15 08:38:31 -05:00

215 lines
9.2 KiB
Swift

import XCTest
/// Integration tests for task operations against the real local backend.
///
/// Test Plan IDs: TASK-010, TASK-012, plus create/edit flows.
/// Data is seeded via API and cleaned up in tearDown.
final class TaskIntegrationTests: AuthenticatedUITestCase {
override var needsAPISession: Bool { true }
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
// MARK: - Create Task
func testTASK_CreateTaskAppearsInList() {
// Seed a residence via API so task creation has a valid target
let residence = cleaner.seedResidence()
navigateToTasks()
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.emptyStateView]
let taskList = app.otherElements[AccessibilityIdentifiers.Task.tasksList]
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|| emptyState.waitForExistence(timeout: 3)
|| taskList.waitForExistence(timeout: 3)
XCTAssertTrue(loaded, "Tasks screen should load")
if addButton.exists && addButton.isHittable {
addButton.forceTap()
} else {
let emptyAddButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
).firstMatch
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
emptyAddButton.forceTap()
}
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
let uniqueTitle = "IntTest Task \(Int(Date().timeIntervalSince1970))"
titleField.forceTap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
titleField.typeText(uniqueTitle)
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
saveButton.scrollIntoView(in: scrollContainer)
saveButton.forceTap()
let newTask = app.staticTexts[uniqueTitle]
XCTAssertTrue(
newTask.waitForExistence(timeout: loginTimeout),
"Newly created task should appear"
)
}
// MARK: - TASK-010: Uncancel Task
func testTASK010_UncancelTaskFlow() throws {
// Seed a cancelled task via API
let residence = cleaner.seedResidence()
let cancelledTask = TestDataSeeder.createCancelledTask(token: session.token, residenceId: residence.id)
cleaner.trackTask(cancelledTask.id)
navigateToTasks()
// Pull to refresh until the cancelled task is visible
let taskText = app.staticTexts[cancelledTask.title]
pullToRefreshUntilVisible(taskText)
guard taskText.waitForExistence(timeout: defaultTimeout) else {
throw XCTSkip("Cancelled task not visible in current view")
}
taskText.forceTap()
// Look for an uncancel or reopen button
let uncancelButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
).firstMatch
if uncancelButton.waitForExistence(timeout: defaultTimeout) {
uncancelButton.forceTap()
let statusText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
).firstMatch
XCTAssertFalse(statusText.exists, "Task should no longer show as cancelled after uncancel")
}
}
// MARK: - TASK-010 (v2): Uncancel Task Restores Cancelled Task to Active Lifecycle
func test15_uncancelRestorescancelledTask() throws {
// Seed a residence and a task, then cancel the task via API
let residence = cleaner.seedResidence(name: "Uncancel Test Residence \(Int(Date().timeIntervalSince1970))")
let task = cleaner.seedTask(residenceId: residence.id, title: "Uncancel Me \(Int(Date().timeIntervalSince1970))")
guard TestAccountAPIClient.cancelTask(token: session.token, id: task.id) != nil else {
throw XCTSkip("Could not cancel task via API — skipping uncancel test")
}
navigateToTasks()
// Pull to refresh until the cancelled task is visible
let taskText = app.staticTexts[task.title]
pullToRefreshUntilVisible(taskText)
guard taskText.waitForExistence(timeout: loginTimeout) else {
throw XCTSkip("Cancelled task '\(task.title)' not visible — may require a Cancelled filter to be active")
}
taskText.forceTap()
// Look for an uncancel / reopen / restore action
let uncancelButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
).firstMatch
guard uncancelButton.waitForExistence(timeout: defaultTimeout) else {
throw XCTSkip("No uncancel button found — feature may not yet be implemented in UI")
}
uncancelButton.forceTap()
// After uncancelling, the task should no longer show a Cancelled status label
let cancelledLabel = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
).firstMatch
XCTAssertFalse(
cancelledLabel.waitForExistence(timeout: defaultTimeout),
"Task should no longer display 'Cancelled' status after being restored"
)
}
// MARK: - TASK-012: Delete Task
func testTASK012_DeleteTaskUpdatesViews() {
// Create a task via UI first (since Kanban board uses cached data)
let residence = cleaner.seedResidence()
navigateToTasks()
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
let emptyAddButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
).firstMatch
let addVisible = addButton.waitForExistence(timeout: defaultTimeout) || emptyAddButton.waitForExistence(timeout: 3)
XCTAssertTrue(addVisible, "Add task button should be visible")
if addButton.exists && addButton.isHittable {
addButton.forceTap()
} else {
emptyAddButton.forceTap()
}
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
let uniqueTitle = "Delete Task \(Int(Date().timeIntervalSince1970))"
titleField.forceTap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
titleField.typeText(uniqueTitle)
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
if scrollContainer.exists {
saveButton.scrollIntoView(in: scrollContainer)
}
saveButton.forceTap()
// Wait for the task to appear in the Kanban board
let taskText = app.staticTexts[uniqueTitle]
taskText.waitForExistenceOrFail(timeout: loginTimeout)
// Tap the "Actions" menu on the task card to reveal cancel option
let actionsMenu = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Actions'")
).firstMatch
if actionsMenu.waitForExistence(timeout: defaultTimeout) {
actionsMenu.forceTap()
} else {
taskText.forceTap()
}
// Tap cancel (tasks use "Cancel Task" semantics)
let deleteButton = app.buttons[AccessibilityIdentifiers.Task.deleteButton]
if !deleteButton.waitForExistence(timeout: defaultTimeout) {
let cancelTask = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Cancel Task'")
).firstMatch
cancelTask.waitForExistenceOrFail(timeout: 5)
cancelTask.forceTap()
} else {
deleteButton.forceTap()
}
// Confirm cancellation
let confirmDelete = app.alerts.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes' OR label CONTAINS[c] 'Cancel Task'")
).firstMatch
let alertConfirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
if alertConfirmButton.waitForExistence(timeout: defaultTimeout) {
alertConfirmButton.tap()
} else if confirmDelete.waitForExistence(timeout: defaultTimeout) {
confirmDelete.tap()
}
// Refresh the task list (kanban uses toolbar button, not pull-to-refresh)
refreshTasks()
// Verify the task is removed or moved to a different column
let deletedTask = app.staticTexts[uniqueTitle]
XCTAssertTrue(
deletedTask.waitForNonExistence(timeout: loginTimeout),
"Cancelled task should no longer appear in active views"
)
}
}