Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 38 additions & 65 deletions SF50 Shared/NOTAM/NOTAMLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -42,7 +58,7 @@ import Sentry
* }
* ```
*/
public actor NOTAMLoader {
public actor NOTAMLoader: NOTAMLoaderProtocol {
/// Shared singleton instance
public static let shared = NOTAMLoader()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task<Void, Never>> = []
private var notamObservationTask: Task<Void, Never>?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -88,6 +89,7 @@ public final class LandingPerformanceViewModel: BasePerformanceViewModel {
super.init(
container: container,
calculationService: calculationService,
notamLoader: notamLoader,
defaultFlapSetting: .flaps100
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -109,6 +110,7 @@ public final class TakeoffPerformanceViewModel: BasePerformanceViewModel {
super.init(
container: container,
calculationService: calculationService,
notamLoader: notamLoader,
defaultFlapSetting: .flaps50
)
}
Expand Down
9 changes: 9 additions & 0 deletions SF50 TOLD/Launch/UITestingHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!)
Expand Down
18 changes: 18 additions & 0 deletions SF50 TOLD/Launch/UITestingNOTAMLoader.swift
Original file line number Diff line number Diff line change
@@ -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] {
[]
}
}
22 changes: 22 additions & 0 deletions SF50 TOLD/Views/Components/TerrainComputingIndicator.swift
Original file line number Diff line number Diff line change
@@ -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()
}
5 changes: 4 additions & 1 deletion SF50 TOLD/Views/Performance/Landing/LandingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ struct GoAroundProfileView: View {
if isComputing {
HStack {
Spacer()
ProgressView("Computing terrain path…")
TerrainComputingIndicator()
Spacer()
}
.padding()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ struct ClimbProfileView: View {
if isComputing {
HStack {
Spacer()
ProgressView("Computing terrain path…")
TerrainComputingIndicator()
Spacer()
}
.padding()
Expand Down
5 changes: 4 additions & 1 deletion SF50 TOLD/Views/Performance/Takeoff/TakeoffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading