diff --git a/SF50 Shared/NOTAM/NOTAMLoader.swift b/SF50 Shared/NOTAM/NOTAMLoader.swift index b4f053b..733ee0c 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: @@ -229,70 +266,6 @@ public actor NOTAMLoader { } } - /// 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 diff --git a/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift b/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift index d8587fa..19def2d 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 6c816b8..1c048a9 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 e8d88f0..0284db1 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 183d724..3111025 100644 --- a/SF50 TOLD/Launch/UITestingHelper.swift +++ b/SF50 TOLD/Launch/UITestingHelper.swift @@ -4,11 +4,20 @@ 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() } + 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 0000000..c9aba19 --- /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/Components/TerrainComputingIndicator.swift b/SF50 TOLD/Views/Components/TerrainComputingIndicator.swift new file mode 100644 index 0000000..d83ab0d --- /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/LandingView.swift b/SF50 TOLD/Views/Performance/Landing/LandingView.swift index 5f11078..dd26cc9 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/Landing/Results/GoAroundProfileView.swift b/SF50 TOLD/Views/Performance/Landing/Results/GoAroundProfileView.swift index 62482f4..3148ba7 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 55851eb..3fb852a 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() diff --git a/SF50 TOLD/Views/Performance/Takeoff/TakeoffView.swift b/SF50 TOLD/Views/Performance/Takeoff/TakeoffView.swift index 2167147..dc29f49 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(