Files
honeyDueKMP/iosApp/HoneyDueUITests/Tests/ResidenceIntegrationTests.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

235 lines
8.9 KiB
Swift

import XCTest
/// Integration tests for residence CRUD against the real local backend.
///
/// Uses a seeded admin account. Data is seeded via API and cleaned up in tearDown.
final class ResidenceIntegrationTests: 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 Residence
func testRES_CreateResidenceAppearsInList() {
navigateToResidences()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
residenceList.openCreateResidence()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
let uniqueName = "IntTest Residence \(Int(Date().timeIntervalSince1970))"
form.enterName(uniqueName)
form.save()
let newResidence = app.staticTexts[uniqueName]
XCTAssertTrue(
newResidence.waitForExistence(timeout: loginTimeout),
"Newly created residence should appear in the list"
)
}
// MARK: - Edit Residence
func testRES_EditResidenceUpdatesInList() {
// Seed a residence via API so we have a known target to edit
let seeded = cleaner.seedResidence(name: "Edit Target \(Int(Date().timeIntervalSince1970))")
navigateToResidences()
pullToRefresh()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Find and tap the seeded residence
let card = app.staticTexts[seeded.name]
pullToRefreshUntilVisible(card, maxRetries: 3)
card.waitForExistenceOrFail(timeout: loginTimeout)
card.forceTap()
// Tap edit button on detail view
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
editButton.forceTap()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
// Clear and re-enter name
let nameField = form.nameField
nameField.waitUntilHittable(timeout: 10).tap()
nameField.press(forDuration: 1.0)
let selectAll = app.menuItems["Select All"]
if selectAll.waitForExistence(timeout: 2) {
selectAll.tap()
}
let updatedName = "Updated Res \(Int(Date().timeIntervalSince1970))"
nameField.typeText(updatedName)
form.save()
let updatedText = app.staticTexts[updatedName]
XCTAssertTrue(
updatedText.waitForExistence(timeout: loginTimeout),
"Updated residence name should appear after edit"
)
}
// MARK: - RES-007: Primary Residence
func test18_setPrimaryResidence() {
// Seed two residences via API; the second one will be promoted to primary
let firstResidence = cleaner.seedResidence(name: "Primary Test A \(Int(Date().timeIntervalSince1970))")
let secondResidence = cleaner.seedResidence(name: "Primary Test B \(Int(Date().timeIntervalSince1970))")
navigateToResidences()
pullToRefresh()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Open the second residence's detail
let secondCard = app.staticTexts[secondResidence.name]
pullToRefreshUntilVisible(secondCard, maxRetries: 3)
secondCard.waitForExistenceOrFail(timeout: loginTimeout)
secondCard.forceTap()
// Tap edit
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
editButton.forceTap()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
// Find and toggle the "is primary" toggle
let isPrimaryToggle = app.switches[AccessibilityIdentifiers.Residence.isPrimaryToggle]
isPrimaryToggle.scrollIntoView(in: app.scrollViews.firstMatch)
isPrimaryToggle.waitForExistenceOrFail(timeout: defaultTimeout)
// Toggle it on (value "0" means off, "1" means on)
if (isPrimaryToggle.value as? String) == "0" {
isPrimaryToggle.forceTap()
}
form.save()
// After saving, a primary indicator should be visible either a label,
// badge, or the toggle being on in the refreshed detail view.
let primaryIndicator = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Primary'")
).firstMatch
let primaryBadge = app.images.containing(
NSPredicate(format: "label CONTAINS[c] 'Primary'")
).firstMatch
let indicatorVisible = primaryIndicator.waitForExistence(timeout: loginTimeout)
|| primaryBadge.waitForExistence(timeout: 3)
XCTAssertTrue(
indicatorVisible,
"A primary residence indicator should appear after setting '\(secondResidence.name)' as primary"
)
// Clean up: remove unused firstResidence id from tracking (already tracked via cleaner)
_ = firstResidence
}
// MARK: - OFF-004: Double Submit Protection
func test19_doubleSubmitProtection() {
navigateToResidences()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
residenceList.openCreateResidence()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
let uniqueName = "DoubleSubmit \(Int(Date().timeIntervalSince1970))"
form.enterName(uniqueName)
// Rapidly tap save twice to test double-submit protection
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
saveButton.forceTap()
// Second tap immediately after if the button is already disabled this will be a no-op
if saveButton.isHittable {
saveButton.forceTap()
}
// Wait for the form to dismiss (sheet closes, we return to the list)
let formDismissed = saveButton.waitForNonExistence(timeout: loginTimeout)
XCTAssertTrue(formDismissed, "Form should dismiss after save")
// Back on the residences list count how many cells with the unique name exist
let matchingTexts = app.staticTexts.matching(
NSPredicate(format: "label == %@", uniqueName)
)
// Allow time for the list to fully load
_ = app.staticTexts[uniqueName].waitForExistence(timeout: defaultTimeout)
XCTAssertEqual(
matchingTexts.count, 1,
"Only one residence named '\(uniqueName)' should exist — double-submit protection should prevent duplicates"
)
// Track the created residence for cleanup
if let residences = TestAccountAPIClient.listResidences(token: session.token) {
if let created = residences.first(where: { $0.name == uniqueName }) {
cleaner.trackResidence(created.id)
}
}
}
// MARK: - Delete Residence
func testRES_DeleteResidenceRemovesFromList() {
// Seed a residence via API don't track it since we'll delete through the UI
let deleteName = "Delete Me \(Int(Date().timeIntervalSince1970))"
TestDataSeeder.createResidence(token: session.token, name: deleteName)
navigateToResidences()
pullToRefresh()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Find and tap the seeded residence
let target = app.staticTexts[deleteName]
pullToRefreshUntilVisible(target, maxRetries: 3)
target.waitForExistenceOrFail(timeout: loginTimeout)
target.forceTap()
// Tap delete button
let deleteButton = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
deleteButton.forceTap()
// Confirm deletion in alert
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
let alertDelete = app.alerts.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
).firstMatch
if confirmButton.waitForExistence(timeout: defaultTimeout) {
confirmButton.tap()
} else if alertDelete.waitForExistence(timeout: defaultTimeout) {
alertDelete.tap()
}
let deletedResidence = app.staticTexts[deleteName]
XCTAssertTrue(
deletedResidence.waitForNonExistence(timeout: loginTimeout),
"Deleted residence should no longer appear in the list"
)
}
}