From 356806f186e57afe944b9aceed9555fa7412cc80 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Thu, 25 Jun 2026 15:39:44 -0700 Subject: [PATCH 1/3] Stub NOTAM loading during UI testing to keep the harness off the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOTAM retrieval went through the concrete `NOTAMLoader.shared` singleton with no test seam, so UI tests hit the live NOTAM API. On a CI runner with no outbound network the request hangs, leaving `isLoadingNOTAMs` true and an indeterminate `ProgressView` (a perpetual CoreAnimation) on the airport row. XCUITest's wait-for-idle never completes against that animation and stalls 60s before every interaction; six such stalls during weather entry pushed testWeatherUserEnteredMode and testLandingResultsValueCorrectness past the 7-minute per-test execution allowance on the iPad CI job. The tests pass locally only because real NOTAMs resolve quickly there. Introduce `NOTAMLoaderProtocol` (mirroring `WeatherLoaderProtocol`), conform `NOTAMLoader`, and inject the loader through `BasePerformanceViewModel` (and its takeoff/landing subclasses). Under `UI-TESTING`, `UITestingHelper` now supplies a `UITestingNOTAMLoader` that returns no NOTAMs immediately — the same pattern weather already uses — so the airport row settles, the app goes idle, and the harness proceeds. Production keeps using the live loader. Co-Authored-By: Claude Opus 4.8 (1M context) --- SF50 Shared/NOTAM/NOTAMLoader.swift | 39 ++++++++++++++++++- .../ViewModel/BasePerformanceViewModel.swift | 13 ++++--- .../LandingPerformanceViewModel.swift | 4 +- .../TakeoffPerformanceViewModel.swift | 4 +- SF50 TOLD/Launch/UITestingHelper.swift | 5 +++ SF50 TOLD/Launch/UITestingNOTAMLoader.swift | 18 +++++++++ .../Performance/Landing/LandingView.swift | 5 ++- .../Performance/Takeoff/TakeoffView.swift | 5 ++- 8 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 SF50 TOLD/Launch/UITestingNOTAMLoader.swift diff --git a/SF50 Shared/NOTAM/NOTAMLoader.swift b/SF50 Shared/NOTAM/NOTAMLoader.swift index b4f053ba..bd036a91 100644 --- a/SF50 Shared/NOTAM/NOTAMLoader.swift +++ b/SF50 Shared/NOTAM/NOTAMLoader.swift @@ -2,6 +2,22 @@ import Foundation import Logging import Sentry +/// Abstracts NOTAM retrieval so callers can inject deterministic data — notably +/// to keep UI tests off the network. +public protocol NOTAMLoaderProtocol: Actor { + /// Fetches NOTAMs for an airport over an effective-date range. + /// - Parameters: + /// - icao: The airport identifier to query. + /// - startDate: Optional start of the effective-date filter. + /// - endDate: Optional end of the effective-date filter. + /// - Returns: The matching NOTAMs. + func fetchNOTAMs( + for icao: String, + startDate: Date?, + endDate: Date? + ) async throws -> [NOTAMResponse] +} + /** * Actor responsible for fetching NOTAM data from the NOTAM API. * @@ -42,7 +58,7 @@ import Sentry * } * ``` */ -public actor NOTAMLoader { +public actor NOTAMLoader: NOTAMLoaderProtocol { /// Shared singleton instance public static let shared = NOTAMLoader() @@ -112,6 +128,27 @@ public actor NOTAMLoader { } } + /// Fetches NOTAMs for an airport over an effective-date range. + /// + /// Satisfies ``NOTAMLoaderProtocol`` by forwarding to + /// ``fetchNOTAMs(for:startDate:endDate:purpose:scope:limit:offset:)`` with + /// default filters and returning just the NOTAM entries. + public func fetchNOTAMs( + for icao: String, + startDate: Date?, + endDate: Date? + ) async throws -> [NOTAMResponse] { + try await fetchNOTAMs( + for: icao, + startDate: startDate, + endDate: endDate, + purpose: nil, + scope: nil, + limit: 100, + offset: 0 + ).data + } + /// Fetches NOTAMs for a specific ICAO location. /// /// - Parameters: diff --git a/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift b/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift index d8587fa2..19def2d3 100644 --- a/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift +++ b/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift @@ -43,6 +43,7 @@ open class BasePerformanceViewModel: WithIdentifiableError { private static let logger = Logger(label: "codes.tim.SF50-TOLD.BasePerformanceViewModel") private let container: ModelContainer + private let notamLoader: any NOTAMLoaderProtocol internal var model: PerformanceModel? private var cancellables: Set> = [] private var notamObservationTask: Task? @@ -126,10 +127,12 @@ open class BasePerformanceViewModel: WithIdentifiableError { public init( container: ModelContainer, calculationService: PerformanceCalculationService = DefaultPerformanceCalculationService.shared, + notamLoader: (any NOTAMLoaderProtocol)? = nil, defaultFlapSetting: FlapSetting ) { self.container = container self.calculationService = calculationService + self.notamLoader = notamLoader ?? NOTAMLoader.shared // temporary values, overwritten by recalculate() model = nil @@ -318,15 +321,15 @@ open class BasePerformanceViewModel: WithIdentifiableError { let endDate = Calendar.current.date(byAdding: .day, value: 30, to: plannedTime) // Try primary identifier first - var response = try await NOTAMLoader.shared.fetchNOTAMs( + var notams = try await notamLoader.fetchNOTAMs( for: primaryIdentifier, startDate: startDate, endDate: endDate ) // If no results and we have a fallback identifier, try that - if response.data.isEmpty, let fallbackIdentifier, fallbackIdentifier != primaryIdentifier { - response = try await NOTAMLoader.shared.fetchNOTAMs( + if notams.isEmpty, let fallbackIdentifier, fallbackIdentifier != primaryIdentifier { + notams = try await notamLoader.fetchNOTAMs( for: fallbackIdentifier, startDate: startDate, endDate: endDate @@ -337,10 +340,10 @@ open class BasePerformanceViewModel: WithIdentifiableError { await NOTAMCache.shared.invalidate(for: primaryIdentifier) // Cache the new results - await NOTAMCache.shared.set(response.data, for: primaryIdentifier) + await NOTAMCache.shared.set(notams, for: primaryIdentifier) // Filter for relevant NOTAMs - downloadedNOTAMs = filterNOTAMs(response.data, relativeTo: plannedTime) + downloadedNOTAMs = filterNOTAMs(notams, relativeTo: plannedTime) // Mark that we've attempted to fetch NOTAMs hasAttemptedNOTAMFetch = true diff --git a/SF50 Shared/Performance/ViewModel/LandingPerformanceViewModel.swift b/SF50 Shared/Performance/ViewModel/LandingPerformanceViewModel.swift index 6c816b86..1c048a98 100644 --- a/SF50 Shared/Performance/ViewModel/LandingPerformanceViewModel.swift +++ b/SF50 Shared/Performance/ViewModel/LandingPerformanceViewModel.swift @@ -78,7 +78,8 @@ public final class LandingPerformanceViewModel: BasePerformanceViewModel { public init( container: ModelContainer, - calculationService: PerformanceCalculationService = DefaultPerformanceCalculationService.shared + calculationService: PerformanceCalculationService = DefaultPerformanceCalculationService.shared, + notamLoader: (any NOTAMLoaderProtocol)? = nil ) { Vref = .notAvailable landingRun = .notAvailable @@ -88,6 +89,7 @@ public final class LandingPerformanceViewModel: BasePerformanceViewModel { super.init( container: container, calculationService: calculationService, + notamLoader: notamLoader, defaultFlapSetting: .flaps100 ) } diff --git a/SF50 Shared/Performance/ViewModel/TakeoffPerformanceViewModel.swift b/SF50 Shared/Performance/ViewModel/TakeoffPerformanceViewModel.swift index e8d88f0b..0284db1e 100644 --- a/SF50 Shared/Performance/ViewModel/TakeoffPerformanceViewModel.swift +++ b/SF50 Shared/Performance/ViewModel/TakeoffPerformanceViewModel.swift @@ -99,7 +99,8 @@ public final class TakeoffPerformanceViewModel: BasePerformanceViewModel { public init( container: ModelContainer, - calculationService: PerformanceCalculationService = DefaultPerformanceCalculationService.shared + calculationService: PerformanceCalculationService = DefaultPerformanceCalculationService.shared, + notamLoader: (any NOTAMLoaderProtocol)? = nil ) { takeoffRun = .notAvailable takeoffDistance = .notAvailable @@ -109,6 +110,7 @@ public final class TakeoffPerformanceViewModel: BasePerformanceViewModel { super.init( container: container, calculationService: calculationService, + notamLoader: notamLoader, defaultFlapSetting: .flaps50 ) } diff --git a/SF50 TOLD/Launch/UITestingHelper.swift b/SF50 TOLD/Launch/UITestingHelper.swift index 183d724c..6df35f6f 100644 --- a/SF50 TOLD/Launch/UITestingHelper.swift +++ b/SF50 TOLD/Launch/UITestingHelper.swift @@ -9,6 +9,11 @@ enum UITestingHelper { return UITestingWeatherLoader() } + static var notamLoader: (any NOTAMLoaderProtocol)? { + guard ProcessInfo.processInfo.arguments.contains("UI-TESTING") else { return nil } + return UITestingNOTAMLoader() + } + static func setupUITestingEnvironment(container: ModelContainer) { // Reset all defaults Defaults.removeAll(suite: UserDefaults(suiteName: "group.codes.tim.TOLD")!) diff --git a/SF50 TOLD/Launch/UITestingNOTAMLoader.swift b/SF50 TOLD/Launch/UITestingNOTAMLoader.swift new file mode 100644 index 00000000..c9aba193 --- /dev/null +++ b/SF50 TOLD/Launch/UITestingNOTAMLoader.swift @@ -0,0 +1,18 @@ +import Foundation +import SF50_Shared + +/// NOTAM loader that returns no NOTAMs immediately. +/// +/// Used during UI testing to eliminate network NOTAM requests. The live +/// ``NOTAMLoader`` reaches the NOTAM API, which is unreachable on a CI runner; +/// the in-progress request would otherwise leave the airport row spinning and +/// stall the test harness's wait-for-idle. +actor UITestingNOTAMLoader: NOTAMLoaderProtocol { + func fetchNOTAMs( + for _: String, + startDate _: Date?, + endDate _: Date? + ) -> [NOTAMResponse] { + [] + } +} diff --git a/SF50 TOLD/Views/Performance/Landing/LandingView.swift b/SF50 TOLD/Views/Performance/Landing/LandingView.swift index 5f11078d..dd26cc97 100644 --- a/SF50 TOLD/Views/Performance/Landing/LandingView.swift +++ b/SF50 TOLD/Views/Performance/Landing/LandingView.swift @@ -29,7 +29,10 @@ struct LandingView: View { .withErrorSheet(state: performance) .onAppear { if performance == nil { - performance = .init(container: modelContext.container) + performance = .init( + container: modelContext.container, + notamLoader: UITestingHelper.notamLoader + ) } if weather == nil { weather = .init( diff --git a/SF50 TOLD/Views/Performance/Takeoff/TakeoffView.swift b/SF50 TOLD/Views/Performance/Takeoff/TakeoffView.swift index 21671471..dc29f498 100644 --- a/SF50 TOLD/Views/Performance/Takeoff/TakeoffView.swift +++ b/SF50 TOLD/Views/Performance/Takeoff/TakeoffView.swift @@ -29,7 +29,10 @@ struct TakeoffView: View { .withErrorSheet(state: performance) .onAppear { if performance == nil { - performance = .init(container: modelContext.container) + performance = .init( + container: modelContext.container, + notamLoader: UITestingHelper.notamLoader + ) } if weather == nil { weather = .init( From 23b78c8a2a16b5732bb34448297a01138837294c Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Thu, 25 Jun 2026 20:18:01 -0700 Subject: [PATCH 2/3] Render the terrain-path computing indicator statically under UI testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The takeoff/landing results screens show `ProgressView("Computing terrain path…")` while an async terrain-path computation runs. An indeterminate `ProgressView` is a perpetual CoreAnimation, so while it's on screen the app never reaches idle and XCUITest's wait-for-idle stalls the full 60s before each interaction. When a test taps through that screen while the computation is in flight — e.g. testNOTAMClearMultipleResetsBadge tapping Back — the stalls compound: locally the test ran 600–1274s and flaked; it is the same wait-for-idle hazard as the NOTAM spinner but with a real, local computation that can't be stubbed away. Add `TerrainComputingIndicator`, which renders the `ProgressView` normally but degrades to a static label under `UI-TESTING`. The computation still runs and the label is unchanged; only the perpetual animation — which XCUITest discards anyway — is dropped, so the app goes idle and the harness proceeds. Production is untouched. Verified: testNOTAMClearMultipleResetsBadge now passes 3/3 at a steady ~137s (was 600–1274s and flaky). Co-Authored-By: Claude Opus 4.8 (1M context) --- SF50 TOLD/Launch/UITestingHelper.swift | 4 ++++ .../TerrainComputingIndicator.swift | 22 +++++++++++++++++++ .../Landing/Results/GoAroundProfileView.swift | 2 +- .../Takeoff/Results/ClimbProfileView.swift | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 SF50 TOLD/Views/Components/TerrainComputingIndicator.swift diff --git a/SF50 TOLD/Launch/UITestingHelper.swift b/SF50 TOLD/Launch/UITestingHelper.swift index 6df35f6f..31110254 100644 --- a/SF50 TOLD/Launch/UITestingHelper.swift +++ b/SF50 TOLD/Launch/UITestingHelper.swift @@ -4,6 +4,10 @@ import SF50_Shared import SwiftData enum UITestingHelper { + static var isUITesting: Bool { + ProcessInfo.processInfo.arguments.contains("UI-TESTING") + } + static var weatherLoader: (any WeatherLoaderProtocol)? { guard ProcessInfo.processInfo.arguments.contains("UI-TESTING") else { return nil } return UITestingWeatherLoader() diff --git a/SF50 TOLD/Views/Components/TerrainComputingIndicator.swift b/SF50 TOLD/Views/Components/TerrainComputingIndicator.swift new file mode 100644 index 00000000..d83ab0d0 --- /dev/null +++ b/SF50 TOLD/Views/Components/TerrainComputingIndicator.swift @@ -0,0 +1,22 @@ +import SwiftUI + +/// Indicates that the terrain path is being computed. +/// +/// Renders an indeterminate ``ProgressView`` in normal use. Under UI testing it +/// degrades to a static label, because an indeterminate spinner is a perpetual +/// animation that keeps the app from going idle — stalling the test harness's +/// wait-for-idle against a background computation the tests don't assert on. +struct TerrainComputingIndicator: View { + var body: some View { + if UITestingHelper.isUITesting { + Text("Computing terrain path…") + .foregroundStyle(.secondary) + } else { + ProgressView("Computing terrain path…") + } + } +} + +#Preview { + TerrainComputingIndicator() +} diff --git a/SF50 TOLD/Views/Performance/Landing/Results/GoAroundProfileView.swift b/SF50 TOLD/Views/Performance/Landing/Results/GoAroundProfileView.swift index 62482f41..3148ba78 100644 --- a/SF50 TOLD/Views/Performance/Landing/Results/GoAroundProfileView.swift +++ b/SF50 TOLD/Views/Performance/Landing/Results/GoAroundProfileView.swift @@ -115,7 +115,7 @@ struct GoAroundProfileView: View { if isComputing { HStack { Spacer() - ProgressView("Computing terrain path…") + TerrainComputingIndicator() Spacer() } .padding() diff --git a/SF50 TOLD/Views/Performance/Takeoff/Results/ClimbProfileView.swift b/SF50 TOLD/Views/Performance/Takeoff/Results/ClimbProfileView.swift index 55851eb9..3fb852a5 100644 --- a/SF50 TOLD/Views/Performance/Takeoff/Results/ClimbProfileView.swift +++ b/SF50 TOLD/Views/Performance/Takeoff/Results/ClimbProfileView.swift @@ -160,7 +160,7 @@ struct ClimbProfileView: View { if isComputing { HStack { Spacer() - ProgressView("Computing terrain path…") + TerrainComputingIndicator() Spacer() } .padding() From 064f755560868c22f331f30e65e87776c719473f Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Thu, 25 Jun 2026 22:41:09 -0700 Subject: [PATCH 3/3] Remove unused NOTAMLoader.fetchNOTAM(id:) The single-NOTAM lookup has no callers anywhere in the app or tests. Routing NOTAM fetches through the new `NOTAMLoaderProtocol` surfaced it to Periphery as dead code. Remove it; the plural `fetchNOTAMs` still uses every `Errors` case and `NOTAMErrorResponse`, so nothing else becomes unused. Co-Authored-By: Claude Opus 4.8 (1M context) --- SF50 Shared/NOTAM/NOTAMLoader.swift | 64 ----------------------------- 1 file changed, 64 deletions(-) diff --git a/SF50 Shared/NOTAM/NOTAMLoader.swift b/SF50 Shared/NOTAM/NOTAMLoader.swift index bd036a91..733ee0c9 100644 --- a/SF50 Shared/NOTAM/NOTAMLoader.swift +++ b/SF50 Shared/NOTAM/NOTAMLoader.swift @@ -266,70 +266,6 @@ public actor NOTAMLoader: NOTAMLoaderProtocol { } } - /// Fetches a single NOTAM by its ID. - /// - /// - Parameter notamId: The NOTAM identifier (e.g., "FDC 2/1234") - /// - Returns: NOTAM response including raw message - /// - Throws: `NOTAMLoader.Errors` on failure - public func fetchNOTAM(id notamId: String) async throws -> NOTAMResponse { - // URL-encode the NOTAM ID - guard - let encodedId = notamId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) - else { - throw Errors.invalidURL - } - - guard let url = URL(string: "\(baseURL)/api/notams/\(encodedId)") else { - throw Errors.invalidURL - } - - Self.logger.info("Fetching single NOTAM", metadata: ["notamId": "\(notamId)"]) - - // Create request with authentication - var request = URLRequest(url: url) - request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.timeoutInterval = 30 - - // Perform request - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw Errors.invalidResponse - } - - // Handle errors - if httpResponse.statusCode != 200 { - if let errorResponse = try? decoder.decode(NOTAMErrorResponse.self, from: data) { - throw Errors.apiError( - statusCode: httpResponse.statusCode, - code: errorResponse.error.code, - message: errorResponse.error.message - ) - } - throw Errors.badResponse(httpResponse) - } - - // Decode response (single NOTAM endpoint returns { "data": {...} }) - struct SingleNOTAMResponse: Decodable { - let data: NOTAMResponse - } - - do { - let singleResponse = try decoder.decode(SingleNOTAMResponse.self, from: data) - Self.logger.info("Successfully fetched NOTAM", metadata: ["notamId": "\(notamId)"]) - return singleResponse.data - } catch { - SentrySDK.capture(error: error) { scope in - scope.setLevel(.warning) - scope.setTag(value: notamId, key: "notam.id") - scope.setFingerprint(["notam-decoding"]) - } - Self.logger.error("Failed to decode NOTAM response", metadata: ["error": "\(error)"]) - throw Errors.decodingFailed(error) - } - } - /// Errors that can occur during NOTAM loading public enum Errors: Error { /// Invalid URL construction