import XCTest /// Document CRUD UI test suite (non-warranty document lifecycle). /// /// Merges `DocumentIntegrationTests` (DOC-002/004/005 + image-section stub, /// integration against the real backend) with the generic document CRUD tests /// from the former `Suite8_DocumentWarrantyTests` (create / edit / delete / /// detail / search / filter / cancel / empty-state / edge-case coverage). /// Warranty-specific scenarios live in `DocumentWarrantyUITests`. /// /// Documents require a residence to create, so `requiresResidence = true` seeds /// one "Precondition Home" residence before login (the app loads it on its /// post-login fetch). Tests that view/edit/delete an EXISTING document seed /// residence + document in `seedAccountPreconditions` (before login). final class DocumentCRUDUITests: AuthenticatedUITestCase { override var requiresResidence: Bool { true } // MARK: - Preconditions /// Documents seeded before login for the edit/delete/image integration tests. /// A fresh account is empty at login, so these must be seeded here (before /// login) rather than in the test body. private(set) var editTargetDoc: TestDocument? private(set) var deleteTargetDoc: TestDocument? private(set) var imageSectionDoc: TestDocument? override func seedAccountPreconditions(_ account: TestAccount) { super.seedAccountPreconditions(account) // seeds the residence (requiresResidence) guard let residence = seededResidence else { return } editTargetDoc = account.seedDocument( residenceId: residence.id, title: "Edit Target Doc \(Int(Date().timeIntervalSince1970))", documentType: "warranty" ) deleteTargetDoc = account.seedDocument( residenceId: residence.id, title: "Delete Doc \(Int(Date().timeIntervalSince1970))", documentType: "warranty" ) imageSectionDoc = account.seedDocument( residenceId: residence.id, title: "Image Section Doc \(Int(Date().timeIntervalSince1970))", documentType: "warranty" ) } // MARK: - Page Objects private var docList: DocumentListScreen { DocumentListScreen(app: app) } private var docForm: DocumentFormScreen { DocumentFormScreen(app: app) } // MARK: - Suite8-style Helpers /// Navigate to the Documents tab, load residence data into the DataManager /// cache (so the property picker is populated), and prime the form once. private func prepareDocumentsScreen() { // Visit Residences tab to load residence data into DataManager cache navigateToResidences() pullToRefresh() // Navigate to the Documents tab navigateToDocuments() // Open and close the document form once to prime the DataManager cache // so the property picker is populated on subsequent opens. let warmupAddButton = docList.addButton if warmupAddButton.exists && warmupAddButton.isEnabled { warmupAddButton.tap() _ = docForm.titleField.waitForExistence(timeout: defaultTimeout) cancelForm() } } private func openDocumentForm(file: StaticString = #filePath, line: UInt = #line) { let addButton = docList.addButton XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add button should exist and be enabled", file: file, line: line) addButton.tap() docForm.titleField.waitForExistenceOrFail(timeout: navigationTimeout, message: "Document form should appear", file: file, line: line) } private func fillTextEditor(text: String) { let textEditor = app.textViews.firstMatch if textEditor.exists { textEditor.focusAndType(text, app: app) } } /// Select a property from the residence picker. Fails the test if picker is missing or empty. private func selectProperty(file: StaticString = #filePath, line: UInt = #line) { // Look up the seeded residence name so we can match it by text in // whichever picker variant iOS renders (menu, list, or wheel). let residences = TestAccountAPIClient.listResidences(token: session.token) ?? [] let residenceName = residences.first?.name let pickerButton = app.buttons[AccessibilityIdentifiers.Document.residencePicker].firstMatch pickerButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Property picker should exist", file: file, line: line) pickerButton.tap() // Fast path: the residence option is often rendered as a plain Button // or StaticText whose label is the residence name itself. Finding it // by text works across menu, list, and wheel picker variants. if let name = residenceName { let byButton = app.buttons[name].firstMatch if byButton.waitForExistence(timeout: 3) && byButton.isHittable { byButton.tap() _ = docForm.titleField.waitForExistence(timeout: navigationTimeout) return } let byText = app.staticTexts[name].firstMatch if byText.exists && byText.isHittable { byText.tap() _ = docForm.titleField.waitForExistence(timeout: navigationTimeout) return } } // SwiftUI Picker in Form renders either a menu (iOS 18+ default) or a // pushed selection list. Detecting the menu requires a slightly longer // wait because the dropdown animates in after the tap. Also: the form // rows themselves are `cells`, so we can't use `cells.firstMatch` to // detect list mode — we must wait longer for a real menu before // falling back. let menuItem = app.menuItems.firstMatch // Give the menu a bit longer to animate; 5s covers the usual case. if menuItem.waitForExistence(timeout: 5) { // Tap the last menu item (the residence option; the placeholder is // index 0 and carries the "Select a Property" label). let allItems = app.menuItems.allElementsBoundByIndex let target = allItems.last ?? menuItem if target.isHittable { target.tap() } else { target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } // Ensure the menu actually dismissed; a lingering overlay blocks // hit-testing on the form below. _ = app.menuItems.firstMatch.waitForNonExistence(timeout: 2) return } else { // List-style picker — find a cell/row with a residence name. // Cells can take a moment to become hittable during the push // animation; retry the tap until the picker dismisses (titleField // reappears on the form) or the attempt budget runs out. let cells = app.cells guard cells.firstMatch.waitForExistence(timeout: navigationTimeout) else { XCTFail("No residence options appeared in picker", file: file, line: line) return } let hittable = NSPredicate(format: "isHittable == true") for attempt in 0..<5 { let targetCell = cells.count > 1 ? cells.element(boundBy: 1) : cells.element(boundBy: 0) guard targetCell.exists else { RunLoop.current.run(until: Date().addingTimeInterval(0.3)) continue } _ = XCTWaiter().wait( for: [XCTNSPredicateExpectation(predicate: hittable, object: targetCell)], timeout: 2.0 + Double(attempt) ) if targetCell.isHittable { targetCell.tap() if docForm.titleField.waitForExistence(timeout: 2) { break } } // Reopen picker if it dismissed without selection. if docForm.titleField.exists, attempt < 4, pickerButton.exists, pickerButton.isHittable { pickerButton.tap() _ = cells.firstMatch.waitForExistence(timeout: 3) } } } // Wait for picker to dismiss and return to form _ = docForm.titleField.waitForExistence(timeout: navigationTimeout) } private func selectDocumentType(type: String) { let typePicker = app.buttons[AccessibilityIdentifiers.Document.typePicker].firstMatch if typePicker.exists { typePicker.tap() let typeButton = app.buttons[type] if typeButton.waitForExistence(timeout: defaultTimeout) { typeButton.tap() } else { // Try cells if it's a navigation style picker let cells = app.cells for i in 0.. Bool { // Open filter menu via accessibility identifier let filterButton = app.buttons[AccessibilityIdentifiers.Common.filterButton].firstMatch guard filterButton.waitForExistence(timeout: defaultTimeout) else { return false } filterButton.forceTap() // Select filter option let filterOption = app.buttons[filterName] if filterOption.waitForExistence(timeout: defaultTimeout) { filterOption.forceTap() _ = filterOption.waitForNonExistence(timeout: defaultTimeout) return true } // Try as static text (some menus render options as text) let filterText = app.staticTexts[filterName] if filterText.exists { filterText.forceTap() _ = filterText.waitForNonExistence(timeout: defaultTimeout) return true } return false } // MARK: - Integration Helpers (DocumentIntegrationTests) /// Navigate to the Documents tab and wait for it to load. private func navigateToDocumentsAndPrepare() { navigateToDocuments() // Wait for the toolbar add-button (or empty-state / list) to confirm // the Documents screen has loaded. let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView] let documentList = app.otherElements[AccessibilityIdentifiers.Document.documentsList] _ = addButton.waitForExistence(timeout: defaultTimeout) || emptyState.waitForExistence(timeout: 3) || documentList.waitForExistence(timeout: 3) } /// Pull-to-refresh on the Documents screen using absolute screen coordinates. /// /// The Warranties tab shows a *horizontal* filter-chip ScrollView above the /// content. `app.scrollViews.firstMatch` picks up the filter chips instead /// of the content, so the base-class `pullToRefresh()` silently fails. /// Working with app-level coordinates avoids this ambiguity. private func pullToRefreshDocuments() { let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35)) let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) start.press(forDuration: 0.3, thenDragTo: end) // Wait for refresh indicator to appear and disappear let refreshIndicator = app.activityIndicators.firstMatch _ = refreshIndicator.waitForExistence(timeout: 3) _ = refreshIndicator.waitForNonExistence(timeout: defaultTimeout) } /// Pull-to-refresh repeatedly until a target element appears or max retries /// reached. Uses `pullToRefreshDocuments()` which targets the correct /// scroll view on the Documents screen. private func pullToRefreshDocumentsUntilVisible(_ element: XCUIElement, maxRetries: Int = 5) { for _ in 0..