import XCTest /// Task create/read/update/delete UI tests. /// /// Merged from the former `Suite5_TaskTests` and `Tests/TaskIntegrationTests`. /// Per-test isolation is provided by `AuthenticatedUITestCase`: every test mints /// a fresh account, logs in, and tears it down. Task creation gates on a residence /// existing, so `requiresResidence` seeds one BEFORE login (the fresh account is /// otherwise empty and the Add-Task button would stay disabled). /// /// Tests that must SEE a pre-existing task (uncancel flows) seed that task in /// `seedAccountPreconditions` so the app loads it on its post-login fetch. final class TaskCRUDUITests: AuthenticatedUITestCase { // Task creation gates on a residence existing; seed one before login so the // fresh account's app sees it (otherwise the Add-Task button stays disabled). override var requiresResidence: Bool { true } // MARK: - Preconditions /// Cancelled task seeded before login for the uncancel flows. A fresh account /// is empty at login, so a task seeded in the test body would be invisible to /// the app without a manual refresh — seed it here instead. private(set) var seededCancelledTask_uncancelFlow: TestTask? private(set) var seededCancelledTask_uncancelV2: TestTask? override func seedAccountPreconditions(_ account: TestAccount) { super.seedAccountPreconditions(account) // seeds seededResidence (requiresResidence) guard let residence = seededResidence else { return } // TASK-010: a cancelled task that the test will uncancel/reopen. seededCancelledTask_uncancelFlow = TestDataSeeder.createCancelledTask( token: account.token, residenceId: residence.id ) // TASK-010 (v2): a named residence+task, cancelled, that the test restores. let v2Task = account.seedTask( residenceId: residence.id, title: "Uncancel Me \(Int(Date().timeIntervalSince1970))" ) seededCancelledTask_uncancelV2 = TestAccountAPIClient.cancelTask(token: account.token, id: v2Task.id) ?? v2Task } override func setUpWithError() throws { try super.setUpWithError() // Dismiss any open form from a previous test let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch if cancelButton.exists { cancelButton.tap() } navigateToTasks() // Wait for task screen to load let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch addButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Task add button should appear") } // MARK: - Validation func test01_cancelTaskCreation() { let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch addButton.tap() let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task form should open") let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch cancelButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Cancel button should exist") cancelButton.tap() // Verify we're back on the task list let addButtonAgain = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch XCTAssertTrue(addButtonAgain.waitForExistence(timeout: navigationTimeout), "Should be back on tasks list after cancel") } // MARK: - View/List func test02_tasksTabExists() { let tabBar = app.tabBars.firstMatch XCTAssertTrue(tabBar.exists, "Tab bar should exist") let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch XCTAssertTrue(addButton.exists, "Task add button should exist (proves we're on Tasks tab)") } func test03_viewTasksList() { // Tasks screen should show — verified by the add button existence from setUp let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch XCTAssertTrue(addButton.exists, "Tasks screen should be visible with add button") } func test04_addTaskButtonEnabled() { let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch XCTAssertTrue(addButton.isEnabled, "Task add button should be enabled when residence exists") } func test05_navigateToAddTask() { let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch addButton.tap() let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear in add form") let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch XCTAssertTrue(saveButton.exists, "Save button should exist in add task form") // Clean up: dismiss form let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch if cancelButton.exists { cancelButton.tap() } } // MARK: - Creation func test06_createBasicTask() { let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch addButton.tap() let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear") let timestamp = Int(Date().timeIntervalSince1970) let taskTitle = "UITest Task \(timestamp)" fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle) dismissKeyboard() app.swipeUp() let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch saveButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Save button should exist") saveButton.tap() // Wait for form to dismiss _ = saveButton.waitForNonExistence(timeout: navigationTimeout) // Verify task was created via API (also gives the server time to process) if let items = TestAccountAPIClient.listTasks(token: session.token), let created = items.first(where: { $0.title.contains(taskTitle) }) { cleaner.trackTask(created.id) } // Navigate to tasks tab and refresh to pick up the newly created task navigateToTasks() refreshTasks() let taskListScreen = TaskListScreen(app: app) let newTask = taskListScreen.findTask(title: taskTitle) XCTAssertTrue(newTask.waitForExistence(timeout: loginTimeout), "New task '\(taskTitle)' should appear in the list") } func testTASK_CreateTaskAppearsInList() { // Residence is seeded before login (requiresResidence) so task creation // has a valid target. 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: - View Details func test07_viewTaskDetails() { // Create a task first let timestamp = Int(Date().timeIntervalSince1970) let taskTitle = "UITest Detail \(timestamp)" let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch addButton.tap() fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle) dismissKeyboard() app.swipeUp() let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch saveButton.waitForExistenceOrFail(timeout: defaultTimeout) saveButton.tap() _ = saveButton.waitForNonExistence(timeout: navigationTimeout) // Verify task was created via API (also gives the server time to process) if let items = TestAccountAPIClient.listTasks(token: session.token), let created = items.first(where: { $0.title.contains(taskTitle) }) { cleaner.trackTask(created.id) } // Navigate to tasks tab and refresh to pick up the newly created task navigateToTasks() refreshTasks() let taskListScreen = TaskListScreen(app: app) let taskCard = taskListScreen.findTask(title: taskTitle) taskCard.waitForExistenceOrFail(timeout: loginTimeout, message: "Created task should appear in list") // Verify the task card is accessible and the actions menu exists // (There is no task detail screen — cards are self-contained with a context menu) let actionsMenu = app.buttons["Task actions"].firstMatch XCTAssertTrue(actionsMenu.waitForExistence(timeout: navigationTimeout), "Task actions menu should be accessible") } // MARK: - Navigation func test08_navigateToContractors() { navigateToContractors() let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Contractors screen should load") } func test09_navigateToDocuments() { navigateToDocuments() let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Documents screen should load") } func test10_navigateBetweenTabs() { navigateToResidences() let resAddButton = app.buttons[AccessibilityIdentifiers.Residence.addButton].firstMatch XCTAssertTrue(resAddButton.waitForExistence(timeout: navigationTimeout), "Residences screen should load") navigateToTasks() let taskAddButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch XCTAssertTrue(taskAddButton.waitForExistence(timeout: navigationTimeout), "Tasks screen should load after navigating back") } // MARK: - TASK-010: Uncancel Task func testTASK010_UncancelTaskFlow() throws { // Cancelled task was seeded BEFORE login (seedAccountPreconditions) so the // app's post-login fetch already has it. guard let cancelledTask = seededCancelledTask_uncancelFlow else { throw XCTSkip("Cancelled task precondition was not seeded") } 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 { // Residence + cancelled task were seeded BEFORE login // (seedAccountPreconditions) so the app loads them on its post-login fetch. guard let task = seededCancelledTask_uncancelV2 else { throw XCTSkip("Cancelled task precondition was not seeded") } 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). // Residence is seeded before login (requiresResidence). 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" ) } }