From 226a2e5ff2d933e258d888cb24636eef96f5afde Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 12:37:30 +0000 Subject: [PATCH 1/2] refactor: extract shared UI test helpers, eliminate sleep() calls, and optimize Home performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create UITestHelpers.swift with shared SeedContent, XCUIApplication navigation extensions, and screenshot convenience — removes ~470 lines of duplication across 4 test files - Replace all 25+ sleep() calls in E2E tests with proper waitForExistence() to make tests faster and less flaky - Fix HomeScreen onAppear re-shuffling quick cards on every tab switch by tracking initial load state and only refreshing mutable sections (checklists, inventory, notes) on tab re-entry - Extract ConnectivityNoticePresenter (@Observable) from duplicated observe/handle/present/auto-dismiss pattern shared by HomeScreen and SettingsScreen (~60 lines removed per screen) - Optimize evaluateSupplyReadiness with Dictionary-based category lookup (O(1) vs O(n) per template item), pre-computed expiry threshold, and pre-lowercased keywords https://claude.ai/code/session_01XrVM9UFJFH5rcwmcW4tcyx --- OSA/Features/Home/HomeScreen.swift | 87 ++--- OSA/Features/Home/HomeSupport.swift | 21 +- OSA/Features/Settings/SettingsScreen.swift | 74 +--- OSA/Shared/Components/ConnectivityBadge.swift | 63 ++++ OSAUITests/OSAAccessibilitySmokeTests.swift | 118 +----- OSAUITests/OSAContentAndInputTests.swift | 356 ++++-------------- OSAUITests/OSAFullE2EVisualTests.swift | 344 +++++------------ OSAUITests/OSARotationUITests.swift | 80 +--- OSAUITests/UITestHelpers.swift | 261 +++++++++++++ 9 files changed, 596 insertions(+), 808 deletions(-) create mode 100644 OSAUITests/UITestHelpers.swift diff --git a/OSA/Features/Home/HomeScreen.swift b/OSA/Features/Home/HomeScreen.swift index 46e2083..b03e8bf 100644 --- a/OSA/Features/Home/HomeScreen.swift +++ b/OSA/Features/Home/HomeScreen.swift @@ -31,7 +31,6 @@ struct HomeScreen: View { private var highContrastMode = AccessibilitySettings.highContrastModeDefault @State private var spotlightMode: SpotlightMode = .quickCards - @State private var connectivity: ConnectivityState = .offline @State private var quickCardsState: HomeSectionState<[QuickCard]> = .loading @State private var feedState: HomeSectionState<[HomeFeedItem]> = .loading @State private var pinnedState: HomeSectionState<[HomePinnedItem]> = .loading @@ -42,15 +41,15 @@ struct HomeScreen: View { @State private var notesState: HomeSectionState<[NoteRecord]> = .loading @State private var readinessSnapshot: SupplyReadinessSnapshot? @State private var showEmergencyMode = false - @State private var connectivityNotice: ConnectivityStatusNotice? - @State private var connectivityNoticeDismissTask: Task? + @State private var hasLoadedInitially = false + @State private var connectivityPresenter = ConnectivityNoticePresenter() var body: some View { ScrollView { VStack(alignment: .leading, spacing: Spacing.lg) { - HomeHeaderView(connectivity: connectivity, onEmergencyModeTapped: openEmergencyMode) - if let connectivityNotice { - ConnectivityStatusCallout(notice: connectivityNotice) + HomeHeaderView(connectivity: connectivityPresenter.connectivity, onEmergencyModeTapped: openEmergencyMode) + if let notice = connectivityPresenter.notice { + ConnectivityStatusCallout(notice: notice) } HomeReadinessSectionView(readinessSnapshot: readinessSnapshot) HomeWeeklyDrillSectionView(state: weeklyDrillState) @@ -73,24 +72,38 @@ struct HomeScreen: View { .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) .onAppear(perform: loadDashboard) - .task { await observeConnectivity() } + .task { + connectivityPresenter.updateReduceMotion(accessibilityReduceMotion) + guard let service = connectivityService else { return } + await connectivityPresenter.observe(service: service) { state, previous in + Self.homeConnectivityNotice(for: state, previousState: previous) + } + } .refreshable { await refreshDashboard() } .fullScreenCover(isPresented: $showEmergencyMode) { EmergencyModeView() } .onDisappear { - connectivityNoticeDismissTask?.cancel() + connectivityPresenter.cancelDismissTask() } } private func loadDashboard() { - reloadLocalSections() + if hasLoadedInitially { + // On tab re-entry, refresh only sections whose underlying data may + // have changed (checklists, inventory, notes, readiness). Quick cards + // keep their stable shuffle to avoid visual churn. + reloadMutableSections() + } else { + hasLoadedInitially = true + reloadAllSections() + } if spotlightMode == .feed, case .loading = feedState { Task { await loadFeed() } } } - private func reloadLocalSections() { + private func reloadAllSections() { loadQuickCards() loadWeeklyDrill() loadPinnedContent() @@ -101,8 +114,18 @@ struct HomeScreen: View { loadRecentNotes() } + /// Refreshes only the sections backed by user-mutable data. + /// Quick cards and weekly drill keep their stable shuffle. + private func reloadMutableSections() { + loadPinnedContent() + loadActiveChecklists() + loadReadinessSnapshot() + loadInventoryReminders() + loadRecentNotes() + } + private func refreshDashboard() async { - reloadLocalSections() + reloadAllSections() if spotlightMode == .feed { await loadFeed() } @@ -496,42 +519,7 @@ struct HomeScreen: View { } } - private func observeConnectivity() async { - guard let service = connectivityService else { return } - var previousState: ConnectivityState? - for await state in service.stateStream() { - handleConnectivityChange(from: previousState, to: state) - previousState = state - } - } - - private func handleConnectivityChange(from previousState: ConnectivityState?, to newState: ConnectivityState) { - guard previousState != newState else { return } - withAnimation(connectivityAnimation) { - connectivity = newState - } - presentConnectivityNotice(homeConnectivityNotice(for: newState, previousState: previousState)) - } - - private func presentConnectivityNotice(_ notice: ConnectivityStatusNotice?) { - connectivityNoticeDismissTask?.cancel() - - withAnimation(connectivityAnimation) { - connectivityNotice = notice - } - - guard let notice, notice.autoDismisses else { return } - - connectivityNoticeDismissTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(4)) - guard !Task.isCancelled, connectivityNotice == notice else { return } - withAnimation(connectivityAnimation) { - connectivityNotice = nil - } - } - } - - private func homeConnectivityNotice( + private static func homeConnectivityNotice( for state: ConnectivityState, previousState: ConnectivityState? ) -> ConnectivityStatusNotice? { @@ -570,11 +558,6 @@ struct HomeScreen: View { } } - private var connectivityAnimation: Animation { - accessibilityReduceMotion - ? .easeOut(duration: 0.12) - : .easeInOut(duration: 0.2) - } } #Preview { diff --git a/OSA/Features/Home/HomeSupport.swift b/OSA/Features/Home/HomeSupport.swift index cfa7c22..70f5362 100644 --- a/OSA/Features/Home/HomeSupport.swift +++ b/OSA/Features/Home/HomeSupport.swift @@ -61,13 +61,24 @@ func evaluateSupplyReadiness( var missingCriticalCount = 0 var nearExpiryCount = 0 + // Pre-compute the expiry threshold once instead of per-item. + let nearExpiryThreshold = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? .distantFuture + + // Group inventory by category for O(1) lookup instead of scanning the + // full list for every template item. + let inventoryByCategory = Dictionary(grouping: inventory, by: \.category) + for templateItem in template.items { let targetQuantity = templateItem.targetQuantity * (templateItem.scalesWithHouseholdSize ? householdSize : 1) - let matches = inventory.filter { item in - guard item.category == templateItem.inventoryCategory else { return false } + let categoryItems = inventoryByCategory[templateItem.inventoryCategory] ?? [] + + // Pre-lowercase keywords once per template item. + let lowercasedKeywords = templateItem.matchKeywords.map { $0.lowercased() } + + let matches = categoryItems.filter { item in + guard !lowercasedKeywords.isEmpty else { return true } let searchableText = "\(item.name) \(item.notes) \(item.location)".lowercased() - return templateItem.matchKeywords.isEmpty - || templateItem.matchKeywords.contains { searchableText.contains($0.lowercased()) } + return lowercasedKeywords.contains { searchableText.contains($0) } } let matchedQuantity = matches.reduce(0) { $0 + $1.quantity } @@ -79,7 +90,7 @@ func evaluateSupplyReadiness( nearExpiryCount += matches.filter { guard let expiryDate = $0.expiryDate else { return false } - return expiryDate <= Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? .distantFuture + return expiryDate <= nearExpiryThreshold }.count } diff --git a/OSA/Features/Settings/SettingsScreen.swift b/OSA/Features/Settings/SettingsScreen.swift index 636e0d2..aaf4f2e 100644 --- a/OSA/Features/Settings/SettingsScreen.swift +++ b/OSA/Features/Settings/SettingsScreen.swift @@ -33,7 +33,6 @@ struct SettingsScreen: View { private var isRSSDiscoveryEnabled = DiscoverySettings.isRSSDiscoveryEnabledDefault @AppStorage(DiscoverySettings.lastDiscoveryDateKey) private var lastDiscoveryTimestamp = 0.0 - @State private var connectivity: ConnectivityState = .offline @State private var braveSearchAPIKey: String @State private var isDiscovering = false @State private var lastDiscoveryMessage: String? @@ -41,8 +40,7 @@ struct SettingsScreen: View { @State private var credentialErrorMessage: String? @State private var contacts: [EmergencyContact] = [] @State private var contactEditor: EmergencyContactEditorState? - @State private var connectivityNotice: ConnectivityStatusNotice? - @State private var connectivityNoticeDismissTask: Task? + @State private var connectivityPresenter = ConnectivityNoticePresenter() @State private var inventoryAlertAuthorizationStatus: InventoryNotificationAuthorizationStatus = .notDetermined @State private var isUpdatingInventoryAlerts = false private let braveSearchCredentialStore: BraveSearchCredentialStore @@ -74,7 +72,11 @@ struct SettingsScreen: View { .task { loadContacts() await refreshInventoryAlertAuthorizationStatus() - await observeConnectivity() + connectivityPresenter.updateReduceMotion(accessibilityReduceMotion) + guard let service = connectivityService else { return } + await connectivityPresenter.observe(service: service) { state, previous in + Self.settingsConnectivityNotice(for: state, previousState: previous) + } } .onChange(of: braveSearchAPIKey) { _, newValue in persistBraveSearchAPIKey(newValue) @@ -108,7 +110,7 @@ struct SettingsScreen: View { } } .onDisappear { - connectivityNoticeDismissTask?.cancel() + connectivityPresenter.cancelDismissTask() } } @@ -279,12 +281,12 @@ struct SettingsScreen: View { @ViewBuilder private var connectivitySection: some View { Section("Connectivity") { - if let connectivityNotice { - ConnectivityStatusCallout(notice: connectivityNotice) + if let notice = connectivityPresenter.notice { + ConnectivityStatusCallout(notice: notice) } LabeledContent("Status") { - ConnectivityBadge(state: connectivity) + ConnectivityBadge(state: connectivityPresenter.connectivity) } LabeledContent("Last Discovery Run") { @@ -414,7 +416,7 @@ struct SettingsScreen: View { lastDiscoveryMessageColor = .osaCritical return } - guard connectivity == .onlineUsable else { + guard connectivityPresenter.connectivity == .onlineUsable else { lastDiscoveryMessage = "Connect to the internet to discover new content." lastDiscoveryMessageColor = .secondary hapticFeedbackService?.play(.warning) @@ -455,22 +457,6 @@ struct SettingsScreen: View { } } - private func observeConnectivity() async { - guard let service = connectivityService else { return } - var previousState: ConnectivityState? - for await state in service.stateStream() { - handleConnectivityChange(from: previousState, to: state) - previousState = state - } - } - - private func handleConnectivityChange(from previousState: ConnectivityState?, to newState: ConnectivityState) { - guard previousState != newState else { return } - withAnimation(connectivityAnimation) { - connectivity = newState - } - presentConnectivityNotice(settingsConnectivityNotice(for: newState, previousState: previousState)) - } private func refreshInventoryAlertAuthorizationStatus() async { guard let inventoryExpiryNotificationService else { @@ -539,25 +525,7 @@ struct SettingsScreen: View { } } - private func presentConnectivityNotice(_ notice: ConnectivityStatusNotice?) { - connectivityNoticeDismissTask?.cancel() - - withAnimation(connectivityAnimation) { - connectivityNotice = notice - } - - guard let notice, notice.autoDismisses else { return } - - connectivityNoticeDismissTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(4)) - guard !Task.isCancelled, connectivityNotice == notice else { return } - withAnimation(connectivityAnimation) { - connectivityNotice = nil - } - } - } - - private func settingsConnectivityNotice( + private static func settingsConnectivityNotice( for state: ConnectivityState, previousState: ConnectivityState? ) -> ConnectivityStatusNotice? { @@ -635,7 +603,7 @@ struct SettingsScreen: View { } private var connectivitySupportText: String { - switch connectivity { + switch connectivityPresenter.connectivity { case .offline: "OSA remains fully usable offline. Connectivity only affects optional discovery and refresh." case .onlineConstrained: @@ -648,7 +616,7 @@ struct SettingsScreen: View { } private var discoveryAvailabilityText: String { - switch connectivity { + switch connectivityPresenter.connectivity { case .offline: "Discovery is unavailable offline. Your local handbook, quick cards, and notes still work." case .onlineConstrained: @@ -661,7 +629,7 @@ struct SettingsScreen: View { } private var discoveryAvailabilityIcon: String { - switch connectivity { + switch connectivityPresenter.connectivity { case .offline: "wifi.slash" case .onlineConstrained: @@ -674,7 +642,7 @@ struct SettingsScreen: View { } private var discoveryAvailabilityTint: Color { - switch connectivity { + switch connectivityPresenter.connectivity { case .offline: .osaBoundary case .onlineConstrained: @@ -687,11 +655,11 @@ struct SettingsScreen: View { } private var discoveryActionDisabled: Bool { - isDiscovering || discoveryCoordinator == nil || connectivity != .onlineUsable + isDiscovering || discoveryCoordinator == nil || connectivityPresenter.connectivity != .onlineUsable } private var discoveryAccessibilityHint: String { - switch connectivity { + switch connectivityPresenter.connectivity { case .offline: "Unavailable offline. Reconnect to check approved sources for new content." case .onlineConstrained: @@ -746,12 +714,6 @@ struct SettingsScreen: View { } } - private var connectivityAnimation: Animation { - accessibilityReduceMotion - ? .easeOut(duration: 0.12) - : .easeInOut(duration: 0.2) - } - private var selectedHazards: Set { Set(UserProfileSettings.hazards(from: hazardsRawValue)) } diff --git a/OSA/Shared/Components/ConnectivityBadge.swift b/OSA/Shared/Components/ConnectivityBadge.swift index 545c502..49b694d 100644 --- a/OSA/Shared/Components/ConnectivityBadge.swift +++ b/OSA/Shared/Components/ConnectivityBadge.swift @@ -199,6 +199,69 @@ struct ConnectivityStatusCallout: View { } } +/// Manages connectivity notice presentation with auto-dismiss. +/// +/// Extracts the observe → handle change → present notice → auto-dismiss pattern +/// shared by HomeScreen and SettingsScreen. +@MainActor @Observable +final class ConnectivityNoticePresenter { + private(set) var notice: ConnectivityStatusNotice? + private(set) var connectivity: ConnectivityState = .offline + private var dismissTask: Task? + private var reduceMotion: Bool + + init(reduceMotion: Bool = false) { + self.reduceMotion = reduceMotion + } + + var animation: Animation { + reduceMotion + ? .easeOut(duration: 0.12) + : .easeInOut(duration: 0.2) + } + + func observe( + service: ConnectivityService, + noticeBuilder: @escaping (ConnectivityState, ConnectivityState?) -> ConnectivityStatusNotice? + ) async { + var previousState: ConnectivityState? + for await state in service.stateStream() { + guard previousState != state else { continue } + withAnimation(animation) { + connectivity = state + } + present(noticeBuilder(state, previousState)) + previousState = state + } + } + + func cancelDismissTask() { + dismissTask?.cancel() + } + + func updateReduceMotion(_ value: Bool) { + reduceMotion = value + } + + private func present(_ newNotice: ConnectivityStatusNotice?) { + dismissTask?.cancel() + + withAnimation(animation) { + notice = newNotice + } + + guard let newNotice, newNotice.autoDismisses else { return } + + dismissTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(4)) + guard !Task.isCancelled, notice == newNotice else { return } + withAnimation(animation) { + notice = nil + } + } + } +} + private struct ConnectivityBadgeAppearance { let dot: Color let label: Color diff --git a/OSAUITests/OSAAccessibilitySmokeTests.swift b/OSAUITests/OSAAccessibilitySmokeTests.swift index 1c21feb..8bc314f 100644 --- a/OSAUITests/OSAAccessibilitySmokeTests.swift +++ b/OSAUITests/OSAAccessibilitySmokeTests.swift @@ -18,7 +18,7 @@ final class OSAAccessibilitySmokeTests: XCTestCase { } func testHomeEmergencyEntryIsAccessible() { - tapTab("Home") + app.tapTab("Home") let emergencyButton = app.buttons["Emergency Mode"] XCTAssertTrue(emergencyButton.waitForExistence(timeout: 3), "Home should expose an Emergency Mode button") @@ -26,7 +26,7 @@ final class OSAAccessibilitySmokeTests: XCTestCase { } func testAskInputAndSubmitControlsAreAccessible() { - tapTab("Ask") + app.tapTab("Ask") let input = app.textFields["Ask a question..."] XCTAssertTrue(input.waitForExistence(timeout: 3), "Ask screen should expose an accessible question input") @@ -37,7 +37,7 @@ final class OSAAccessibilitySmokeTests: XCTestCase { } func testInventoryExportActionIsAccessible() { - tapTab("Inventory") + app.tapTab("Inventory") let exportButton = app.buttons["Export inventory"] XCTAssertTrue(exportButton.waitForExistence(timeout: 3), "Inventory should expose an export action") @@ -45,7 +45,7 @@ final class OSAAccessibilitySmokeTests: XCTestCase { } func testEmergencyModeExitAndPrimaryActionAreAccessible() { - tapTab("Home") + app.tapTab("Home") let emergencyButton = app.buttons["Emergency Mode"] XCTAssertTrue(emergencyButton.waitForExistence(timeout: 3), "Emergency Mode button missing") @@ -65,9 +65,9 @@ final class OSAAccessibilitySmokeTests: XCTestCase { } func testQuickCardDetailPinControlIsAccessible() { - navigateToMoreItem("Quick Cards") + app.navigateToMoreItem("Quick Cards") - guard let cardButton = firstQuickCardButton() else { + guard let cardButton = app.firstQuickCardButton() else { XCTFail("Quick Cards list should contain at least one quick card") return } @@ -82,9 +82,9 @@ final class OSAAccessibilitySmokeTests: XCTestCase { } func testQuickCardAndHandbookShareControlsAreAccessible() { - navigateToMoreItem("Quick Cards") + app.navigateToMoreItem("Quick Cards") - guard let quickCard = firstQuickCardButton() else { + guard let quickCard = app.firstQuickCardButton() else { XCTFail("Quick Cards list should contain at least one seeded card") return } @@ -99,7 +99,7 @@ final class OSAAccessibilitySmokeTests: XCTestCase { backButton.tap() XCTAssertTrue( - openLibraryChapter(named: "Preparedness Foundations"), + app.openLibraryChapter(named: "Preparedness Foundations"), "Preparedness Foundations chapter missing from Library" ) @@ -113,7 +113,7 @@ final class OSAAccessibilitySmokeTests: XCTestCase { } func testEmergencyModeSurvivalToolsShortcutIsAccessible() { - tapTab("Home") + app.tapTab("Home") let emergencyButton = app.buttons["Emergency Mode"] XCTAssertTrue(emergencyButton.waitForExistence(timeout: 3), "Emergency Mode button missing") @@ -128,31 +128,31 @@ final class OSAAccessibilitySmokeTests: XCTestCase { } func testSettingsAccessibilityControlsExist() { - navigateToMoreItem("Settings") + app.navigateToMoreItem("Settings") let extendedSettingsScrollDepth = 12 let largePrintToggle = app.switches["Large print reading mode"] XCTAssertTrue( - scrollToElement(largePrintToggle), + app.scrollToElement(largePrintToggle), "Settings should expose Large print reading mode toggle" ) let languagePicker = app.segmentedControls["settings-app-language-picker"] XCTAssertTrue( - scrollToElement(languagePicker), + app.scrollToElement(languagePicker), "Settings should expose the app language picker" ) let highContrastToggle = app.switches["settings-high-contrast-toggle"] XCTAssertTrue( - scrollToElement(highContrastToggle), + app.scrollToElement(highContrastToggle), "Settings should expose High contrast mode" ) let addContact = app.buttons["Add Emergency Contact"] XCTAssertTrue( - scrollToElement(addContact, maxSwipes: extendedSettingsScrollDepth), + app.scrollToElement(addContact, maxSwipes: extendedSettingsScrollDepth), "Settings should expose Add Emergency Contact" ) @@ -160,31 +160,31 @@ final class OSAAccessibilitySmokeTests: XCTestCase { .matching(NSPredicate(format: "label CONTAINS[c] %@", "I'm Safe")) .firstMatch XCTAssertTrue( - scrollToElement(safeShortcutCopy, maxSwipes: extendedSettingsScrollDepth), + app.scrollToElement(safeShortcutCopy, maxSwipes: extendedSettingsScrollDepth), "Settings should explain how emergency contacts support the I'm Safe shortcut" ) let criticalHapticsToggle = app.switches["Critical haptics"] XCTAssertTrue( - scrollToElement(criticalHapticsToggle, maxSwipes: extendedSettingsScrollDepth), + app.scrollToElement(criticalHapticsToggle, maxSwipes: extendedSettingsScrollDepth), "Settings should expose Critical haptics" ) let inventoryAlertsToggle = app.switches["Local expiry reminders"] XCTAssertTrue( - scrollToElement(inventoryAlertsToggle, maxSwipes: extendedSettingsScrollDepth), + app.scrollToElement(inventoryAlertsToggle, maxSwipes: extendedSettingsScrollDepth), "Settings should expose local expiry reminder controls" ) let discoveryButton = app.buttons["Discover New Content"] XCTAssertTrue( - scrollToElement(discoveryButton, maxSwipes: extendedSettingsScrollDepth), + app.scrollToElement(discoveryButton, maxSwipes: extendedSettingsScrollDepth), "Settings should expose Discover New Content" ) } func testLibraryContentTypeFiltersAreAccessible() { - tapTab("Library") + app.tapTab("Library") let searchField = app.searchFields.firstMatch XCTAssertTrue(searchField.waitForExistence(timeout: 3), "Library should expose a search field") @@ -200,82 +200,4 @@ final class OSAAccessibilitySmokeTests: XCTestCase { let summary = app.staticTexts["Content Type: Quick Cards"] XCTAssertTrue(summary.waitForExistence(timeout: 3), "Library should expose the active content-type summary") } - - private func tapTab(_ name: String) { - let button = app.tabBars.firstMatch.buttons[name] - if button.waitForExistence(timeout: 3) { - button.tap() - } - } - - private func navigateToMoreItem(_ label: String) { - tapTab("More") - - let item = app.staticTexts[label] - if item.waitForExistence(timeout: 3) { - item.tap() - return - } - - let button = app.buttons[label] - if button.waitForExistence(timeout: 2) { - button.tap() - } - } - - private func openLibraryChapter(named title: String) -> Bool { - tapTab("Library") - - let chapter = app.staticTexts[title] - guard scrollToElement(chapter, maxSwipes: 6) else { - return false - } - - chapter.tap() - return true - } - - private func scrollToElement(_ element: XCUIElement, maxSwipes: Int = 6) -> Bool { - if element.waitForExistence(timeout: 1) { - return true - } - - for _ in 0.. XCUIElement? { - let quickCardLabels = [ - "Earthquake Drop-Cover-Hold", - "First Hour Power Outage Check", - "Boil Water Advisory Steps", - "Gas Leak Response", - "Go-Bag Grab List", - "Family Meeting Point Reminder", - "Severe Weather Shelter Steps", - "Refrigerator Food Safety Timer", - "Water Rotation Check", - "Home Medication Check", - "Smoke And CO Detector Check", - "Vehicle Breakdown Safety Steps", - "Utility Shutoff Quick Reference", - "Winter Storm Home Preparation" - ] - - for label in quickCardLabels { - let button = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", label)).firstMatch - if button.waitForExistence(timeout: 1) { - return button - } - } - - return nil - } - } diff --git a/OSAUITests/OSAContentAndInputTests.swift b/OSAUITests/OSAContentAndInputTests.swift index af867e7..2c72c13 100644 --- a/OSAUITests/OSAContentAndInputTests.swift +++ b/OSAUITests/OSAContentAndInputTests.swift @@ -28,7 +28,7 @@ final class OSAContentAndInputTests: XCTestCase { func testLibraryChapterSectionsHaveContent() { XCTAssertTrue( - openLibraryChapter(named: "Preparedness Foundations"), + app.openLibraryChapter(named: "Preparedness Foundations"), "Preparedness Foundations chapter missing from Library" ) @@ -44,7 +44,7 @@ final class OSAContentAndInputTests: XCTestCase { func testWaterChapterSectionsAreReadable() { XCTAssertTrue( - openLibraryChapter(named: "Water"), + app.openLibraryChapter(named: "Water"), "Water chapter missing from Library" ) @@ -59,49 +59,29 @@ final class OSAContentAndInputTests: XCTestCase { } func testQuickCardContentIsReadable() { - tapTab("Home") - - let quickCardLabels = [ - "Earthquake Drop-Cover-Hold", - "First Hour Power Outage Check", - "Boil Water Advisory Steps", - "Gas Leak Response", - "Go-Bag Grab List", - "Family Meeting Point Reminder", - "Severe Weather Shelter Steps", - "Refrigerator Food Safety Timer", - "Water Rotation Check", - "Home Medication Check", - "Smoke And CO Detector Check", - "Vehicle Breakdown Safety Steps", - "Utility Shutoff Quick Reference", - "Winter Storm Home Preparation" - ] - - guard let cardLabel = quickCardLabels.first(where: { - app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", $0)).firstMatch.waitForExistence(timeout: 1) - }) else { + app.tapTab("Home") + + guard let cardLabel = app.firstVisibleQuickCardLabel() else { XCTFail("No quick card found on Home") return } let card = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", cardLabel)).firstMatch - let cardTitle = cardLabel card.tap() XCTAssertTrue( - app.navigationBars[cardTitle].waitForExistence(timeout: 3) + app.navigationBars[cardLabel].waitForExistence(timeout: 3) || app.staticTexts["Stored locally"].waitForExistence(timeout: 3), "Quick card detail should open" ) } func testSettingsLanguagePickerCanSwitchToSpanish() { - navigateToMoreItem("Settings") + app.navigateToMoreItem("Settings") let languagePicker = app.segmentedControls["settings-app-language-picker"] XCTAssertTrue( - scrollToElement(languagePicker), + app.scrollToElement(languagePicker), "Settings should expose the app language picker" ) @@ -117,29 +97,20 @@ final class OSAContentAndInputTests: XCTestCase { } func testQuickCardDetailLoadsWithLargePrintEnabled() { - navigateToMoreItem("Settings") + app.navigateToMoreItem("Settings") let largePrintToggle = app.switches["settings-large-print-toggle"] XCTAssertTrue( - scrollToElement(largePrintToggle), + app.scrollToElement(largePrintToggle), "Settings should expose the large print toggle" ) if "\(largePrintToggle.value)" == "0" { largePrintToggle.tap() } - navigateToMoreItem("Quick Cards") - - let quickCardLabels = [ - "Earthquake Drop-Cover-Hold", - "Gas Leak Response", - "Boil Water Advisory Steps", - "First Hour Power Outage Check" - ] + app.navigateToMoreItem("Quick Cards") - guard let cardLabel = quickCardLabels.first(where: { - app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", $0)).firstMatch.waitForExistence(timeout: 1) - }) else { + guard let cardLabel = app.firstVisibleQuickCardLabel() else { XCTFail("Expected seeded quick card missing") return } @@ -155,24 +126,24 @@ final class OSAContentAndInputTests: XCTestCase { } func testCreateAndViewNote() { - navigateToMoreItem("Notes") + app.navigateToMoreItem("Notes") - openNewNoteComposer() + app.openNewNoteComposer() XCTAssertTrue( app.textFields["Title"].waitForExistence(timeout: 3) || app.textFields.firstMatch.waitForExistence(timeout: 3), "Note composer should show a title field" ) - dismissModal() + app.dismissModal() } func testCreateInventoryItem() { - tapTab("Inventory") + app.tapTab("Inventory") - let addButton = findButton(labelContaining: "Add") - ?? findButton(labelContaining: "plus") - ?? findButton(labelContaining: "New") + let addButton = app.findButton(labelContaining: "Add") + ?? app.findButton(labelContaining: "plus") + ?? app.findButton(labelContaining: "New") XCTAssertNotNil(addButton, "Inventory screen should provide an add button") addButton?.tap() @@ -183,15 +154,15 @@ final class OSAContentAndInputTests: XCTestCase { "Inventory form should show a name field" ) - dismissModal() + app.dismissModal() } func testInventoryFormShowsSprint11CaptureAffordances() { - tapTab("Inventory") + app.tapTab("Inventory") - let addButton = findButton(labelContaining: "Add") - ?? findButton(labelContaining: "plus") - ?? findButton(labelContaining: "New") + let addButton = app.findButton(labelContaining: "Add") + ?? app.findButton(labelContaining: "plus") + ?? app.findButton(labelContaining: "New") XCTAssertNotNil(addButton, "Inventory screen should provide an add button") addButton?.tap() @@ -212,11 +183,11 @@ final class OSAContentAndInputTests: XCTestCase { "Inventory form should show a local OCR action" ) - dismissModal() + app.dismissModal() } func testInventoryScreenShowsExportAction() { - tapTab("Inventory") + app.tapTab("Inventory") let exportButton = app.buttons["Export inventory"] XCTAssertTrue( @@ -226,11 +197,10 @@ final class OSAContentAndInputTests: XCTestCase { } func testChecklistTemplateAndRunShowExportActions() { - navigateToMoreItem("Checklists") + app.navigateToMoreItem("Checklists") - let templateTitle = "72-Hour Emergency Kit Check" let template = app.buttons["checklist-template-72-hour-emergency-kit-check"] - guard scrollToElement(template, maxSwipes: 10) else { + guard app.scrollToElement(template, maxSwipes: 10) else { XCTFail("Expected standard checklist template missing") return } @@ -249,20 +219,21 @@ final class OSAContentAndInputTests: XCTestCase { // "Start Checklist" is in the last list section and may be off-screen on first render let startButton = app.buttons["Start Checklist"] - guard scrollToElement(startButton, maxSwipes: 3) else { + guard app.scrollToElement(startButton, maxSwipes: 3) else { XCTFail("Checklist template detail should expose a start action") return } + let templateTitle = "72-Hour Emergency Kit Check" startButton.tap() - tapTab("Home") + app.tapTab("Home") let activeRunQuery = app.buttons.matching(identifier: "home-checklist-run-\(templateTitle)") - var activeRun = firstHittableElement(in: activeRunQuery) + var activeRun = app.firstHittableElement(in: activeRunQuery) for _ in 0..<6 where activeRun == nil { app.swipeUp() - activeRun = firstHittableElement(in: activeRunQuery) + activeRun = app.firstHittableElement(in: activeRunQuery) } guard let activeRun else { @@ -280,7 +251,7 @@ final class OSAContentAndInputTests: XCTestCase { } func testQuickCardsSearch() { - navigateToMoreItem("Quick Cards") + app.navigateToMoreItem("Quick Cards") let searchField = app.searchFields.firstMatch XCTAssertTrue(searchField.waitForExistence(timeout: 3), "Quick Cards search field should appear") @@ -295,11 +266,11 @@ final class OSAContentAndInputTests: XCTestCase { } func testHomeWeeklyDrillOpensPracticeReadyQuickCard() { - tapTab("Home") + app.tapTab("Home") let weeklyDrill = app.buttons["home-weekly-drill-card"] XCTAssertTrue( - scrollToElement(weeklyDrill, maxSwipes: 3), + app.scrollToElement(weeklyDrill, maxSwipes: 3), "Home should expose the weekly drill card" ) @@ -313,18 +284,18 @@ final class OSAContentAndInputTests: XCTestCase { } func testLibraryShowsRopeAndKnotsReferenceQuizEntry() { - tapTab("Library") + app.tapTab("Library") let categoryCell = app.cells.containing(.staticText, identifier: "Rope And Knots").firstMatch XCTAssertTrue( - scrollToElement(categoryCell, maxSwipes: 6), + app.scrollToElement(categoryCell, maxSwipes: 6), "Library should show the Rope And Knots field reference category" ) categoryCell.tap() let entryCell = app.cells.containing(.staticText, identifier: "Bowline Reference").firstMatch XCTAssertTrue( - scrollToElement(entryCell, maxSwipes: 4), + app.scrollToElement(entryCell, maxSwipes: 4), "Bowline Reference should appear in the Rope And Knots category" ) entryCell.tap() @@ -337,7 +308,7 @@ final class OSAContentAndInputTests: XCTestCase { } func testToolsScreenShowsMorseConverterAndDeclination() { - navigateToMoreItem("Tools") + app.navigateToMoreItem("Tools") XCTAssertTrue( app.navigationBars["Tools"].waitForExistence(timeout: 3) @@ -358,19 +329,19 @@ final class OSAContentAndInputTests: XCTestCase { let converter = app.staticTexts["Unit Converter"] XCTAssertTrue( - scrollToElement(converter), + app.scrollToElement(converter), "Tools screen should expose the unit converter" ) let declination = app.staticTexts["Declination"] XCTAssertTrue( - scrollToElement(declination), + app.scrollToElement(declination), "Tools screen should expose the declination section" ) } func testLibrarySearch() { - tapTab("Library") + app.tapTab("Library") let searchField = app.searchFields.firstMatch XCTAssertTrue(searchField.waitForExistence(timeout: 3), "Search field not found in Library") @@ -386,7 +357,7 @@ final class OSAContentAndInputTests: XCTestCase { } func testLibrarySearchShowsContentTypeFiltersAndSelection() { - tapTab("Library") + app.tapTab("Library") let searchField = app.searchFields.firstMatch XCTAssertTrue(searchField.waitForExistence(timeout: 3), "Search field not found in Library") @@ -410,7 +381,7 @@ final class OSAContentAndInputTests: XCTestCase { func testLibraryShowsRecentlyViewedAfterOpeningSection() { XCTAssertTrue( - openLibraryChapter(named: "Preparedness Foundations"), + app.openLibraryChapter(named: "Preparedness Foundations"), "Preparedness Foundations chapter missing from Library" ) @@ -423,20 +394,20 @@ final class OSAContentAndInputTests: XCTestCase { "Section detail should allow navigation back" ) - navigateBack() + app.navigateBack() if !app.staticTexts["Recently Viewed"].exists { - navigateBack() + app.navigateBack() } - scrollLibraryToTop() + app.scrollToTop() XCTAssertTrue( - scrollToElement(app.staticTexts["Recently Viewed"], maxSwipes: 2), + app.scrollToElement(app.staticTexts["Recently Viewed"], maxSwipes: 2), "Library should show Recently Viewed after opening a handbook section" ) } func testAskInputBarAcceptsQuery() { - tapTab("Ask") + app.tapTab("Ask") let textField = app.textFields["Ask a question..."] XCTAssertTrue(textField.waitForExistence(timeout: 3), "Ask screen should show a query field") @@ -451,9 +422,9 @@ final class OSAContentAndInputTests: XCTestCase { } func testAskShowsRecentQuestionsAndStudyGuideActionAfterAnswer() { - tapTab("Ask") + app.tapTab("Ask") - submitAskQuestion("Boil Water Advisory Steps") + app.submitAskQuestion("Boil Water Advisory Steps") XCTAssertTrue( app.staticTexts["Recent Questions"].waitForExistence(timeout: 8), @@ -466,10 +437,10 @@ final class OSAContentAndInputTests: XCTestCase { } func testAskRecentQuestionCanBeTappedToRerun() { - tapTab("Ask") + app.tapTab("Ask") let question = "Boil Water Advisory Steps" - submitAskQuestion(question) + app.submitAskQuestion(question) let textField = app.textFields["Ask a question..."] XCTAssertTrue(textField.waitForExistence(timeout: 3), "Ask screen should keep its input visible") @@ -495,9 +466,9 @@ final class OSAContentAndInputTests: XCTestCase { } func testQuickCardAndHandbookDetailShowShareActions() { - navigateToMoreItem("Quick Cards") + app.navigateToMoreItem("Quick Cards") - guard let quickCard = firstQuickCardButton() else { + guard let quickCard = app.firstQuickCardButton() else { XCTFail("Quick Cards should list at least one seeded card") return } @@ -508,9 +479,9 @@ final class OSAContentAndInputTests: XCTestCase { "Quick card detail should expose a share action" ) - navigateBack() + app.navigateBack() XCTAssertTrue( - openLibraryChapter(named: "Preparedness Foundations"), + app.openLibraryChapter(named: "Preparedness Foundations"), "Preparedness Foundations chapter missing from Library" ) @@ -525,7 +496,7 @@ final class OSAContentAndInputTests: XCTestCase { } func testNotesFlowShowsFamilyPlanEntryPointAndExportActions() { - navigateToMoreItem("Notes") + app.navigateToMoreItem("Notes") let noteTitle = "Export Test Note \(UUID().uuidString.prefix(6))" let createNoteButton = app.buttons["Create note"] @@ -567,7 +538,7 @@ final class OSAContentAndInputTests: XCTestCase { } func testSettingsShowsEmergencyContactPurposeAndDiscoveryControls() { - navigateToMoreItem("Settings") + app.navigateToMoreItem("Settings") let extendedSettingsScrollDepth = 12 @@ -575,25 +546,25 @@ final class OSAContentAndInputTests: XCTestCase { .matching(NSPredicate(format: "label CONTAINS[c] %@", "I'm Safe")) .firstMatch XCTAssertTrue( - scrollToElement(safeShortcutCopy, maxSwipes: extendedSettingsScrollDepth), + app.scrollToElement(safeShortcutCopy, maxSwipes: extendedSettingsScrollDepth), "Settings should explain how emergency contacts support the I'm Safe shortcut" ) let criticalHaptics = app.switches["Critical haptics"] XCTAssertTrue( - scrollToElement(criticalHaptics, maxSwipes: extendedSettingsScrollDepth), + app.scrollToElement(criticalHaptics, maxSwipes: extendedSettingsScrollDepth), "Settings should surface critical haptics controls" ) let discoveryButton = app.buttons["Discover New Content"] XCTAssertTrue( - scrollToElement(discoveryButton, maxSwipes: extendedSettingsScrollDepth), + app.scrollToElement(discoveryButton, maxSwipes: extendedSettingsScrollDepth), "Settings should surface the discovery action" ) } func testDocumentVaultOpensInLockedState() { - navigateToMoreItem("Document Vault") + app.navigateToMoreItem("Document Vault") XCTAssertTrue( app.staticTexts["Vault Locked"].waitForExistence(timeout: 3) @@ -603,12 +574,12 @@ final class OSAContentAndInputTests: XCTestCase { } func testSettingsExposeKnowledgePackManagement() { - navigateToMoreItem("Settings") + app.navigateToMoreItem("Settings") let knowledgePackLink = app.otherElements["settings-knowledge-packs"] let knowledgePackText = app.staticTexts["Knowledge Packs"] XCTAssertTrue( - scrollToElement(knowledgePackLink, maxSwipes: 10) || scrollToElement(knowledgePackText, maxSwipes: 10), + app.scrollToElement(knowledgePackLink, maxSwipes: 10) || app.scrollToElement(knowledgePackText, maxSwipes: 10), "Settings should expose Knowledge Packs inside the knowledge-discovery area" ) @@ -626,7 +597,7 @@ final class OSAContentAndInputTests: XCTestCase { let waterPackAction = app.buttons["knowledge-pack-action-water-readiness"] XCTAssertTrue( - scrollToElement(waterPackAction, maxSwipes: 4), + app.scrollToElement(waterPackAction, maxSwipes: 4), "Bundled knowledge packs should be visible in Settings" ) XCTAssertEqual( @@ -637,16 +608,16 @@ final class OSAContentAndInputTests: XCTestCase { } func testMapScreenCanSaveWaypoint() { - openMapScreen() - handleLocationPermissionIfNeeded() + app.openMapScreen() + app.handleLocationPermissionIfNeeded() let saveWaypointButton = app.buttons["Save Visible Waypoint"] - if scrollToElement(saveWaypointButton, maxSwipes: 2) { + if app.scrollToElement(saveWaypointButton, maxSwipes: 2) { saveWaypointButton.tap() } else { let saveWaypointTile = app.otherElements["Save visible waypoint"] XCTAssertTrue( - scrollToElement(saveWaypointTile, maxSwipes: 2), + app.scrollToElement(saveWaypointTile, maxSwipes: 2), "Map should expose a visible-waypoint save action" ) saveWaypointTile.tap() @@ -664,191 +635,8 @@ final class OSAContentAndInputTests: XCTestCase { let waypointRow = app.staticTexts[waypointTitle] XCTAssertTrue( - scrollToElement(waypointRow, maxSwipes: 4), + app.scrollToElement(waypointRow, maxSwipes: 4), "Saved waypoint should appear in the Map screen waypoint list" ) } - - private func tapTab(_ name: String) { - let tabBar = app.tabBars.firstMatch - let button = tabBar.buttons[name] - if button.waitForExistence(timeout: 3) { - button.tap() - } - } - - private func navigateToMoreItem(_ label: String) { - tapTab("More") - - let item = app.staticTexts[label] - if item.waitForExistence(timeout: 3) { - item.tap() - return - } - - let button = app.buttons[label] - if button.waitForExistence(timeout: 2) { - button.tap() - } - } - - private func openLibraryChapter(named title: String) -> Bool { - tapTab("Library") - - let chapter = app.staticTexts[title] - guard scrollToElement(chapter, maxSwipes: 6) else { - return false - } - - chapter.tap() - return true - } - - private func scrollLibraryToTop() { - for _ in 0..<3 { - app.swipeDown() - } - } - - private func dismissModal() { - let cancel = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch - if cancel.waitForExistence(timeout: 2) { - cancel.tap() - return - } - - app.swipeDown() - } - - private func navigateBack() { - let backButton = app.navigationBars.buttons.firstMatch - if backButton.waitForExistence(timeout: 2) { - backButton.tap() - } - } - - private func handleLocationPermissionIfNeeded() { - let allowWhileUsing = app.buttons["Allow While Using App"] - if allowWhileUsing.waitForExistence(timeout: 2) { - allowWhileUsing.tap() - return - } - - let allowOnce = app.buttons["Allow Once"] - if allowOnce.waitForExistence(timeout: 2) { - allowOnce.tap() - } - } - - private func openMapScreen() { - tapTab("Map") - if app.buttons["Save Visible Waypoint"].waitForExistence(timeout: 2) { - return - } - - if app.otherElements["Save visible waypoint"].waitForExistence(timeout: 2) { - return - } - - navigateToMoreItem("Map") - } - - private func findButton(labelContaining text: String) -> XCUIElement? { - let predicate = NSPredicate(format: "label CONTAINS[c] %@", text) - let navButton = app.navigationBars.buttons.matching(predicate).firstMatch - if navButton.waitForExistence(timeout: 2) { return navButton } - - let button = app.buttons.matching(predicate).firstMatch - if button.exists { return button } - - return nil - } - - private func scrollToElement(_ element: XCUIElement, maxSwipes: Int = 6) -> Bool { - if element.waitForExistence(timeout: 1) { - return true - } - - for _ in 0.. XCUIElement? { - query.allElementsBoundByIndex.first(where: \.isHittable) - } - - private func openNewNoteComposer() { - let createNoteButton = app.buttons["Create note"] - if createNoteButton.waitForExistence(timeout: 3) { - createNoteButton.tap() - - let newNoteAction = app.buttons["New Note"] - if newNoteAction.waitForExistence(timeout: 3) { - newNoteAction.tap() - return - } - } - - let createFirstNoteButton = app.buttons["Create First Note"] - if createFirstNoteButton.waitForExistence(timeout: 2) { - createFirstNoteButton.tap() - } - } - - private func submitAskQuestion(_ question: String) { - let textField = app.textFields["Ask a question..."] - XCTAssertTrue(textField.waitForExistence(timeout: 3), "Ask screen should show a query field") - textField.tap() - textField.typeText(question) - - let submitButton = app.buttons["Submit question"] - if submitButton.exists { - submitButton.tap() - return - } - - if app.keyboards.buttons["Return"].exists { - app.keyboards.buttons["Return"].tap() - return - } - - if app.keyboards.buttons["return"].exists { - app.keyboards.buttons["return"].tap() - } - } - - private func firstQuickCardButton() -> XCUIElement? { - let quickCardLabels = [ - "Earthquake Drop-Cover-Hold", - "First Hour Power Outage Check", - "Boil Water Advisory Steps", - "Gas Leak Response", - "Go-Bag Grab List", - "Family Meeting Point Reminder", - "Severe Weather Shelter Steps", - "Refrigerator Food Safety Timer", - "Water Rotation Check", - "Home Medication Check", - "Smoke And CO Detector Check", - "Vehicle Breakdown Safety Steps", - "Utility Shutoff Quick Reference", - "Winter Storm Home Preparation" - ] - - for label in quickCardLabels { - let button = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", label)).firstMatch - if button.waitForExistence(timeout: 1) { - return button - } - } - - return nil - } - } diff --git a/OSAUITests/OSAFullE2EVisualTests.swift b/OSAUITests/OSAFullE2EVisualTests.swift index 2560166..0328cb7 100644 --- a/OSAUITests/OSAFullE2EVisualTests.swift +++ b/OSAUITests/OSAFullE2EVisualTests.swift @@ -43,7 +43,7 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testHomeScreenContent() { - tapTab("Home") + app.tapTab("Home") // Hero brand card — BrandWordmarkView renders as Image with accessibility label let brandImage = app.images["Lantern"] @@ -62,23 +62,7 @@ final class OSAFullE2EVisualTests: XCTestCase { ) // At least one quick card (randomized on each launch) - let quickCardLabels = [ - "Earthquake Drop-Cover-Hold", - "First Hour Power Outage Check", - "Boil Water Advisory Steps", - "Gas Leak Response", - "Go-Bag Grab List", - "Family Meeting Point Reminder", - "Severe Weather Shelter Steps", - "Refrigerator Food Safety Timer", - "Water Rotation Check", - "Home Medication Check", - "Smoke And CO Detector Check", - "Vehicle Breakdown Safety Steps", - "Utility Shutoff Quick Reference", - "Winter Storm Home Preparation" - ] - let anyCardVisible = quickCardLabels.contains { app.staticTexts[$0].exists } + let anyCardVisible = SeedContent.quickCardLabels.contains { app.staticTexts[$0].exists } XCTAssertTrue(anyCardVisible, "At least one quick card should appear on Home") // Active Checklists section @@ -87,49 +71,33 @@ final class OSAFullE2EVisualTests: XCTestCase { "Active Checklists section header should appear on Home" ) - screenshot("Home-Tab") + screenshot("Home-Tab", app: app) } @MainActor func testHomeTapQuickCard() { - tapTab("Home") - - // Quick cards are randomized; tap whichever one appears first - let quickCardLabels = [ - "Earthquake Drop-Cover-Hold", - "First Hour Power Outage Check", - "Boil Water Advisory Steps", - "Gas Leak Response", - "Go-Bag Grab List", - "Family Meeting Point Reminder", - "Severe Weather Shelter Steps", - "Refrigerator Food Safety Timer", - "Water Rotation Check", - "Home Medication Check", - "Smoke And CO Detector Check", - "Vehicle Breakdown Safety Steps", - "Utility Shutoff Quick Reference", - "Winter Storm Home Preparation" - ] - guard let cardLabel = quickCardLabels.first(where: { - app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", $0)).firstMatch.waitForExistence(timeout: 1) - }) else { + app.tapTab("Home") + + guard let cardLabel = app.firstVisibleQuickCardLabel() else { XCTFail("No quick card found on Home") return } let card = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", cardLabel)).firstMatch card.tap() - sleep(1) - screenshot("Home-QuickCard-Detail") + // Wait for detail to load instead of sleeping + let detailLoaded = app.navigationBars.firstMatch.waitForExistence(timeout: 3) + || app.staticTexts["Stored locally"].waitForExistence(timeout: 3) + XCTAssertTrue(detailLoaded, "Quick card detail should load after tap") + + screenshot("Home-QuickCard-Detail", app: app) - // Navigate back - navigateBack() + app.navigateBack() } @MainActor func testHomeSpotlightFeedTab() { - tapTab("Home") + app.tapTab("Home") // Segmented picker should have "Feed" segment let feedSegment = app.buttons["Feed"] @@ -138,11 +106,8 @@ final class OSAFullE2EVisualTests: XCTestCase { return } feedSegment.tap() - sleep(3) - - screenshot("Home-Spotlight-Feed") - // After tapping Feed, we should see either articles or an informational state + // Wait for feed content to resolve instead of sleeping 3s let anyArticle = app.staticTexts.matching( NSPredicate(format: "label CONTAINS[c] 'Read more'") ).firstMatch @@ -150,29 +115,32 @@ final class OSAFullE2EVisualTests: XCTestCase { let failedState = app.staticTexts["Feed service unavailable."] let loadingState = app.staticTexts["Fetching latest articles..."] - let feedResolved = anyArticle.exists || emptyState.exists || failedState.exists || loadingState.exists + // Give feed time to load — check periodically + let feedResolved = anyArticle.waitForExistence(timeout: 5) + || emptyState.waitForExistence(timeout: 2) + || failedState.waitForExistence(timeout: 2) + || loadingState.exists XCTAssertTrue(feedResolved, "Feed tab should show articles, empty state, or loading indicator") + screenshot("Home-Spotlight-Feed", app: app) + // Switch back to Quick Cards to verify toggle works let quickCardsSegment = app.buttons["Quick Cards"] if quickCardsSegment.exists { quickCardsSegment.tap() - sleep(1) - screenshot("Home-Spotlight-QuickCards") + screenshot("Home-Spotlight-QuickCards", app: app) } } @MainActor func testHomeScrollToBottom() { - tapTab("Home") + app.tapTab("Home") app.swipeUp() - sleep(1) - screenshot("Home-Scrolled-1") + screenshot("Home-Scrolled-1", app: app) app.swipeUp() - sleep(1) - screenshot("Home-Scrolled-2") + screenshot("Home-Scrolled-2", app: app) // Bottom sections may or may not have data — just verify no crash } @@ -181,7 +149,7 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testLibraryScreenContent() { - tapTab("Library") + app.tapTab("Library") let fieldReferences = app.staticTexts["Field References"] XCTAssertTrue( @@ -191,28 +159,33 @@ final class OSAFullE2EVisualTests: XCTestCase { let firstChapter = app.staticTexts["Preparedness Foundations"] XCTAssertTrue( - scrollToElement(firstChapter, maxSwipes: 6), + app.scrollToElement(firstChapter, maxSwipes: 6), "Preparedness Foundations chapter should remain reachable in Library" ) let waterChapter = app.staticTexts["Water"] XCTAssertTrue( - waterChapter.exists || scrollToElement(waterChapter, maxSwipes: 2), + waterChapter.exists || app.scrollToElement(waterChapter, maxSwipes: 2), "Water chapter should appear in Library" ) - screenshot("Library-Tab") + screenshot("Library-Tab", app: app) } @MainActor func testLibraryDrillIntoChapter() { - guard openLibraryChapter(named: "Preparedness Foundations") else { + guard app.openLibraryChapter(named: "Preparedness Foundations") else { XCTFail("Preparedness Foundations chapter not found") return } - sleep(1) - screenshot("Library-Chapter-Detail") + // Wait for chapter detail to load + XCTAssertTrue( + app.navigationBars["Preparedness Foundations"].waitForExistence(timeout: 3), + "Chapter detail should show navigation title" + ) + + screenshot("Library-Chapter-Detail", app: app) // Should show sections — at least some text content let hasSections = app.cells.count > 0 || app.staticTexts.count > 2 @@ -221,33 +194,33 @@ final class OSAFullE2EVisualTests: XCTestCase { let section = app.staticTexts["Start With The Risks You Actually Face"] if section.waitForExistence(timeout: 2) { section.tap() - sleep(1) - screenshot("Library-Section-Detail") - navigateBack() + + // Wait for section detail to load + _ = app.navigationBars.firstMatch.waitForExistence(timeout: 3) + screenshot("Library-Section-Detail", app: app) + app.navigateBack() } if !app.staticTexts["Recently Viewed"].exists { - navigateBack() + app.navigateBack() } - scrollLibraryToTop() + app.scrollToTop() XCTAssertTrue( - scrollToElement(app.staticTexts["Recently Viewed"], maxSwipes: 2), + app.scrollToElement(app.staticTexts["Recently Viewed"], maxSwipes: 2), "Library should show Recently Viewed after opening a handbook section" ) - screenshot("Library-Recently-Viewed") + screenshot("Library-Recently-Viewed", app: app) } @MainActor func testLibraryScrollChapterList() { - tapTab("Library") - sleep(1) + app.tapTab("Library") + _ = app.staticTexts["Field References"].waitForExistence(timeout: 3) app.swipeUp() - sleep(1) app.swipeUp() - sleep(1) - screenshot("Library-Scrolled") + screenshot("Library-Scrolled", app: app) // Check a chapter further down the list let fireChapter = app.staticTexts["Fire And Lighting"] @@ -255,8 +228,8 @@ final class OSAFullE2EVisualTests: XCTestCase { XCTAssertTrue( fireChapter.exists || goChapter.exists - || scrollToElement(fireChapter, maxSwipes: 4) - || scrollToElement(goChapter, maxSwipes: 4), + || app.scrollToElement(fireChapter, maxSwipes: 4) + || app.scrollToElement(goChapter, maxSwipes: 4), "Later chapters should remain reachable after scrolling" ) } @@ -265,9 +238,9 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testAskScreenContent() { - tapTab("Ask") + app.tapTab("Ask") - screenshot("Ask-Tab") + screenshot("Ask-Tab", app: app) // Ask screen should show some form of UI — text field, prompt, or scope controls let hasAskUI = app.textFields.count > 0 @@ -283,9 +256,9 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testInventoryScreenContent() { - tapTab("Inventory") + app.tapTab("Inventory") - screenshot("Inventory-Tab") + screenshot("Inventory-Tab", app: app) // May show empty state or category-grouped items // Just verify the screen loaded without crash @@ -295,30 +268,31 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testInventoryAddItem() { - tapTab("Inventory") + app.tapTab("Inventory") // Look for add button in nav bar or toolbar - let addButton = findButton(labelContaining: "Add") - ?? findButton(labelContaining: "plus") - ?? findButton(labelContaining: "New") + let addButton = app.findButton(labelContaining: "Add") + ?? app.findButton(labelContaining: "plus") + ?? app.findButton(labelContaining: "New") guard let addButton else { return } // No add button is OK addButton.tap() - sleep(1) - screenshot("Inventory-Add-Item") - // Dismiss - dismissModal() + // Wait for form to appear instead of sleeping + _ = app.textFields.firstMatch.waitForExistence(timeout: 3) + screenshot("Inventory-Add-Item", app: app) + + app.dismissModal() } // MARK: - More Tab > Checklists @MainActor func testChecklistsScreen() { - navigateToMoreItem("Checklists") + app.navigateToMoreItem("Checklists") - screenshot("Checklists-Screen") + screenshot("Checklists-Screen", app: app) // Look for a seed checklist template let goChecklist = app.staticTexts.matching( @@ -333,9 +307,11 @@ final class OSAFullE2EVisualTests: XCTestCase { // Tap into a template if found if anyChecklist.exists { anyChecklist.tap() - sleep(1) - screenshot("Checklist-Template-Detail") - navigateBack() + + // Wait for detail to load + _ = app.navigationBars.firstMatch.waitForExistence(timeout: 3) + screenshot("Checklist-Template-Detail", app: app) + app.navigateBack() } } @@ -343,16 +319,20 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testQuickCardsScreen() { - navigateToMoreItem("Quick Cards") + app.navigateToMoreItem("Quick Cards") - screenshot("QuickCards-Screen") + screenshot("QuickCards-Screen", app: app) let searchField = app.searchFields.firstMatch if searchField.waitForExistence(timeout: 3) { searchField.tap() searchField.typeText("water") - sleep(1) - screenshot("QuickCards-Search") + + // Wait for search results + _ = app.buttons.matching( + NSPredicate(format: "label CONTAINS[c] 'Water'") + ).firstMatch.waitForExistence(timeout: 3) + screenshot("QuickCards-Search", app: app) } let firstCard = app.buttons.matching( @@ -360,9 +340,11 @@ final class OSAFullE2EVisualTests: XCTestCase { ).firstMatch if firstCard.waitForExistence(timeout: 3) { firstCard.tap() - sleep(1) - screenshot("QuickCard-Detail-FromList") - navigateBack() + + // Wait for detail to load + _ = app.navigationBars.firstMatch.waitForExistence(timeout: 3) + screenshot("QuickCard-Detail-FromList", app: app) + app.navigateBack() } } @@ -370,9 +352,9 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testToolsScreen() { - navigateToMoreItem("Tools") + app.navigateToMoreItem("Tools") - screenshot("Tools-Screen") + screenshot("Tools-Screen", app: app) let morseSection = app.staticTexts["Morse Signal"] let timerSection = app.staticTexts["Timer / Stopwatch"] @@ -390,7 +372,6 @@ final class OSAFullE2EVisualTests: XCTestCase { if !converterSection.exists { app.swipeUp() - sleep(1) } XCTAssertTrue( @@ -403,20 +384,22 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testNotesScreen() { - navigateToMoreItem("Notes") + app.navigateToMoreItem("Notes") - screenshot("Notes-Screen") + screenshot("Notes-Screen", app: app) // Try add note - let addButton = findButton(labelContaining: "Add") - ?? findButton(labelContaining: "New") - ?? findButton(labelContaining: "plus") + let addButton = app.findButton(labelContaining: "Add") + ?? app.findButton(labelContaining: "New") + ?? app.findButton(labelContaining: "plus") if let addButton { addButton.tap() - sleep(1) - screenshot("Notes-New-Note") - dismissModal() + + // Wait for composer to appear + _ = app.textFields.firstMatch.waitForExistence(timeout: 3) + screenshot("Notes-New-Note", app: app) + app.dismissModal() } } @@ -424,9 +407,9 @@ final class OSAFullE2EVisualTests: XCTestCase { @MainActor func testSettingsScreen() { - navigateToMoreItem("Settings") + app.navigateToMoreItem("Settings") - screenshot("Settings-Screen") + screenshot("Settings-Screen", app: app) // Look for reorganized setup and status sections plus About/Version fallback let emergencyContacts = app.staticTexts["Emergency Contacts"] @@ -436,7 +419,7 @@ final class OSAFullE2EVisualTests: XCTestCase { .firstMatch let aboutSection = app.staticTexts["About"] let versionLabel = app.staticTexts["Version"] - let lanternLabel = app.staticTexts[AppBrand.subtitle] + let lanternLabel = app.staticTexts[TestAppBrand.subtitle] let anySettingsContent = emergencyContacts.waitForExistence(timeout: 3) || accessibilitySection.exists || safeShortcutCopy.exists @@ -446,131 +429,4 @@ final class OSAFullE2EVisualTests: XCTestCase { || app.switches.count > 0 XCTAssertTrue(anySettingsContent, "Settings should show About, Version, or toggle controls") } - - // MARK: - Helpers - - @MainActor - private func tapTab(_ name: String) { - let tabBar = app.tabBars.firstMatch - let button = tabBar.buttons[name] - if button.waitForExistence(timeout: 3) { - button.tap() - sleep(1) - } - } - - @MainActor - private func navigateToMoreItem(_ label: String) { - tapTab("More") - - // On iPhone with sidebarAdaptable, "More" presents a list - let item = app.staticTexts[label] - if item.waitForExistence(timeout: 3) { - item.tap() - sleep(1) - return - } - - // Fallback — try buttons or cells - let button = app.buttons[label] - if button.waitForExistence(timeout: 2) { - button.tap() - sleep(1) - return - } - - let cell = app.cells.matching( - NSPredicate(format: "label CONTAINS[c] %@", label) - ).firstMatch - if cell.waitForExistence(timeout: 2) { - cell.tap() - sleep(1) - } - } - - @MainActor - private func openLibraryChapter(named title: String) -> Bool { - tapTab("Library") - - let chapter = app.staticTexts[title] - guard scrollToElement(chapter, maxSwipes: 6) else { - return false - } - - chapter.tap() - return true - } - - @MainActor - private func scrollLibraryToTop() { - for _ in 0..<3 { - app.swipeDown() - sleep(1) - } - } - - @MainActor - private func navigateBack() { - let backButton = app.navigationBars.buttons.firstMatch - if backButton.waitForExistence(timeout: 2) { - backButton.tap() - sleep(1) - } - } - - @MainActor - private func dismissModal() { - let cancel = app.buttons.matching( - NSPredicate(format: "label CONTAINS[c] 'Cancel'") - ).firstMatch - if cancel.exists { - cancel.tap() - sleep(1) - } else { - app.swipeDown() - sleep(1) - } - } - - @MainActor - private func findButton(labelContaining text: String) -> XCUIElement? { - let predicate = NSPredicate(format: "label CONTAINS[c] %@", text) - let navButton = app.navigationBars.buttons.matching(predicate).firstMatch - if navButton.waitForExistence(timeout: 2) { return navButton } - - let toolbarButton = app.buttons.matching(predicate).firstMatch - if toolbarButton.exists { return toolbarButton } - - return nil - } - - @MainActor - private func scrollToElement(_ element: XCUIElement, maxSwipes: Int = 6) -> Bool { - if element.waitForExistence(timeout: 1) { - return true - } - - for _ in 0.. XCUIElement { - let quickCardLabels = [ - "Earthquake Drop-Cover-Hold", - "First Hour Power Outage Check", - "Boil Water Advisory Steps", - "Gas Leak Response", - "Go-Bag Grab List", - "Family Meeting Point Reminder", - "Severe Weather Shelter Steps", - "Refrigerator Food Safety Timer", - "Water Rotation Check", - "Home Medication Check", - "Smoke And CO Detector Check", - "Vehicle Breakdown Safety Steps", - "Utility Shutoff Quick Reference", - "Winter Storm Home Preparation" - ] - - for label in quickCardLabels { - let candidate = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", label)).firstMatch - if candidate.waitForExistence(timeout: 1) { - return candidate - } - } - - return app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'Quick Card'")).firstMatch - } - - @MainActor - private func waitForUIToSettle() { + // Wait for layout to settle after rotation _ = app.tabBars.firstMatch.waitForExistence(timeout: 3) - sleep(1) - } - - @MainActor - private func scrollToElement(_ element: XCUIElement, maxSwipes: Int = 6) -> Bool { - if element.waitForExistence(timeout: 1) { - return true - } - - for _ in 0.. Bool { + tapTab("Library") + + let chapter = staticTexts[title] + guard scrollToElement(chapter, maxSwipes: 6) else { + return false + } + + chapter.tap() + return true + } + + /// Scrolls down (swipe up) until the element is visible or `maxSwipes` is exhausted. + @MainActor + func scrollToElement(_ element: XCUIElement, maxSwipes: Int = 6) -> Bool { + if element.waitForExistence(timeout: 1) { + return true + } + + for _ in 0.. XCUIElement? { + let predicate = NSPredicate(format: "label CONTAINS[c] %@", text) + let navButton = navigationBars.buttons.matching(predicate).firstMatch + if navButton.waitForExistence(timeout: 2) { return navButton } + + let toolbarButton = buttons.matching(predicate).firstMatch + if toolbarButton.exists { return toolbarButton } + + return nil + } + + /// Returns the first visible quick card button on the current screen, or `nil`. + @MainActor + func firstQuickCardButton() -> XCUIElement? { + for label in SeedContent.quickCardLabels { + let button = buttons.matching( + NSPredicate(format: "label CONTAINS[c] %@", label) + ).firstMatch + if button.waitForExistence(timeout: 1) { + return button + } + } + return nil + } + + /// Returns the label of the first visible quick card, or `nil`. + @MainActor + func firstVisibleQuickCardLabel() -> String? { + for label in SeedContent.quickCardLabels { + let match = buttons.matching( + NSPredicate(format: "label CONTAINS[c] %@", label) + ).firstMatch + if match.waitForExistence(timeout: 1) { + return label + } + } + return nil + } + + /// Handles the iOS location permission alert if it appears. + @MainActor + func handleLocationPermissionIfNeeded() { + let allowWhileUsing = buttons["Allow While Using App"] + if allowWhileUsing.waitForExistence(timeout: 2) { + allowWhileUsing.tap() + return + } + + let allowOnce = buttons["Allow Once"] + if allowOnce.waitForExistence(timeout: 2) { + allowOnce.tap() + } + } + + /// Opens the Map screen, handling both tab-bar and More-tab routing. + @MainActor + func openMapScreen() { + tapTab("Map") + if buttons["Save Visible Waypoint"].waitForExistence(timeout: 2) { + return + } + if otherElements["Save visible waypoint"].waitForExistence(timeout: 2) { + return + } + navigateToMoreItem("Map") + } + + /// Submits a question on the Ask screen. + @MainActor + func submitAskQuestion(_ question: String) { + let textField = textFields["Ask a question..."] + guard textField.waitForExistence(timeout: 3) else { return } + textField.tap() + textField.typeText(question) + + let submitButton = buttons["Submit question"] + if submitButton.exists { + submitButton.tap() + return + } + + if keyboards.buttons["Return"].exists { + keyboards.buttons["Return"].tap() + return + } + + if keyboards.buttons["return"].exists { + keyboards.buttons["return"].tap() + } + } + + /// Opens the New Note composer from the Notes screen. + @MainActor + func openNewNoteComposer() { + let createNoteButton = buttons["Create note"] + if createNoteButton.waitForExistence(timeout: 3) { + createNoteButton.tap() + + let newNoteAction = buttons["New Note"] + if newNoteAction.waitForExistence(timeout: 3) { + newNoteAction.tap() + return + } + } + + let createFirstNoteButton = buttons["Create First Note"] + if createFirstNoteButton.waitForExistence(timeout: 2) { + createFirstNoteButton.tap() + } + } + + /// Returns the first hittable element from a query, or `nil`. + @MainActor + func firstHittableElement(in query: XCUIElementQuery) -> XCUIElement? { + query.allElementsBoundByIndex.first(where: \.isHittable) + } +} + +// MARK: - Screenshot Convenience + +extension XCTestCase { + /// Captures a named screenshot attachment from the given app. + @MainActor + func screenshot(_ name: String, app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} + +// MARK: - Brand Constants for Test Assertions + +enum TestAppBrand { + static let subtitle = "Offline Preparedness Guide" +} From eb92ef95cf8a99e52f52bbe399affba6c35efdbd Mon Sep 17 00:00:00 2001 From: Anthony Johnson II Date: Mon, 30 Mar 2026 05:46:45 -0700 Subject: [PATCH 2/2] Update OSAUITests/OSARotationUITests.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- OSAUITests/OSARotationUITests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/OSAUITests/OSARotationUITests.swift b/OSAUITests/OSARotationUITests.swift index edfb067..653965c 100644 --- a/OSAUITests/OSARotationUITests.swift +++ b/OSAUITests/OSARotationUITests.swift @@ -1,4 +1,5 @@ import XCTest +import UIKit final class OSARotationUITests: XCTestCase { private var app: XCUIApplication!