Skip to content

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,46 @@ actor NavDataLoader {
self.state = .loading(progress: Float(airportCount + obstaclesProcessed) / Float(totalItems))
}

try writeCycles(nasr.cycles)

state = .finished
return LoadResult(
cycles: nasr.cycles,
ourAirportsLastUpdated: nasr.ourAirportsLastUpdated
)
}

/// Deletes all persisted ``Cycle`` records on the loader's background context.
///
/// Performed off the main thread so it never contends with the main
/// `NSManagedObjectContext` for the persistent store coordinator.
func clearCycles() throws {
try modelContext.delete(model: Cycle.self)
try modelContext.save()
}

private func writeCycles(_ cycles: AirportDataCodable.DataCycles) throws {
insertCycle(cycles.nasr, source: .nasr)
insertCycle(cycles.cifp, source: .cifp)
insertCycle(cycles.dof, source: .dof)
try modelContext.save()
}

private func insertCycle(
_ info: AirportDataCodable.CycleInfo?,
source: CycleDataSource
) {
guard let info else { return }
modelContext.insert(
Cycle(
dataSource: source,
name: info.name,
effective: info.effective,
expires: info.expires
)
)
}

private func download(progress: (Float) -> Void) async throws -> Data {
try await withRetry(logger: logger, label: "nav data") {
let session = URLSession(configuration: .ephemeral)
Expand Down
133 changes: 41 additions & 92 deletions SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,56 +45,51 @@ final class NavDataLoaderViewModel: WithIdentifiableError {

init(container: ModelContainer) {
self.container = container
do {
try recalculate()
} catch {
SentrySDK.capture(error: error) { scope in
scope.setFingerprint(["navData", "recalculate"])
}
self.error = error
}

setupObservation()
}

private func setupObservation() {
addTask(
Task {
for await _ in Defaults.updates(.schemaVersion)
where !Task.isCancelled {
do {
try recalculate()
} catch {
SentrySDK.capture(error: error) { scope in
scope.setFingerprint(["navData", "recalculate"])
}
self.error = error
}
}
addTask(schemaVersionObservationTask())
addTask(statePollingTask())
}

private func schemaVersionObservationTask() -> Task<Void, Never> {
Task.detached { [container] in
let context = ModelContext(container)
for await _ in Defaults.updates(.schemaVersion)
where !Task.isCancelled {
await self.refreshState(from: context, fingerprint: "recalculate")
}
)
}
}

addTask(
Task.detached { [container] in
do {
let context = ModelContext(container)
while !Task.isCancelled {
let state = try NavDataStateHelper.fetchState(context: context)
await MainActor.run {
self.applyState(state)
}
try? await Task.sleep(for: .seconds(0.5))
}
} catch {
await MainActor.run {
SentrySDK.capture(error: error) { scope in
scope.setFingerprint(["navData", "airportCheck"])
}
self.error = error
}
private func statePollingTask() -> Task<Void, Never> {
Task.detached { [container] in
let context = ModelContext(container)
while !Task.isCancelled {
await self.refreshState(from: context, fingerprint: "airportCheck")
try? await Task.sleep(for: .seconds(0.5))
}
}
}

nonisolated private func refreshState(
from context: ModelContext,
fingerprint: String
) async {
do {
let state = try NavDataStateHelper.fetchState(context: context)
await MainActor.run {
self.applyState(state)
}
} catch {
await MainActor.run {
SentrySDK.capture(error: error) { scope in
scope.setFingerprint(["navData", fingerprint])
}
self.error = error
}
)
}
}

private func addTask(_ task: Task<Void, Never>) {
Expand All @@ -112,46 +107,12 @@ final class NavDataLoaderViewModel: WithIdentifiableError {
)
do {
error = nil
try clearCycles()
try await loader.clearCycles()
Defaults[.ourAirportsLastUpdated] = nil
let result = try await loader.load()

await MainActor.run {
let context = container.mainContext
if let nasr = result.cycles.nasr {
context.insert(
Cycle(
dataSource: .nasr,
name: nasr.name,
effective: nasr.effective,
expires: nasr.expires
)
)
}
if let cifp = result.cycles.cifp {
context.insert(
Cycle(
dataSource: .cifp,
name: cifp.name,
effective: cifp.effective,
expires: cifp.expires
)
)
}
if let dof = result.cycles.dof {
context.insert(
Cycle(
dataSource: .dof,
name: dof.name,
effective: dof.effective,
expires: dof.expires
)
)
}
try? context.save()
try? self.recalculate()
Defaults[.ourAirportsLastUpdated] = result.ourAirportsLastUpdated
Defaults[.schemaVersion] = latestSchemaVersion
}
Defaults[.ourAirportsLastUpdated] = result.ourAirportsLastUpdated
Defaults[.schemaVersion] = latestSchemaVersion
transaction.finish()
} catch {
transaction.finish(status: .internalError)
Expand Down Expand Up @@ -179,18 +140,6 @@ final class NavDataLoaderViewModel: WithIdentifiableError {
if canSkip { deferred = true }
}

private func clearCycles() throws {
let context = container.mainContext
try context.delete(model: Cycle.self)
try context.save()
Defaults[.ourAirportsLastUpdated] = nil
}

private func recalculate() throws {
let state = try NavDataStateHelper.fetchState(context: container.mainContext)
applyState(state)
}

private func applyState(_ state: NavDataStateHelper.State) {
if noData != state.noData { self.noData = state.noData }
if needsLoad != state.needsLoad { self.needsLoad = state.needsLoad }
Expand Down
9 changes: 5 additions & 4 deletions SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -656,9 +656,10 @@ private extension Error {
/// Whether this error (or any error in its `NSUnderlyingErrorKey` chain)
/// represents a POSIX `ENOSPC` "No space left on device" condition.
///
/// `StreamingLZMA` wraps the underlying `errno` string in a String-typed
/// `LZMAError.internalError` case, so the string fallback is needed when
/// the typed POSIX error has been lost.
/// `StreamingLZMA` surfaces a failed-write `errno` as `LZMAError.ioFailure`,
/// which bridges (via `CustomNSError`) to an `NSError` carrying an
/// `NSPOSIXErrorDomain` underlying error, so the typed checks below are
/// sufficient.
var isOutOfDiskSpace: Bool {
let nsError = self as NSError
if nsError.domain == NSPOSIXErrorDomain, nsError.code == Int(ENOSPC) {
Expand All @@ -670,6 +671,6 @@ private extension Error {
if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? Error {
return underlying.isOutOfDiskSpace
}
return localizedDescription.contains("No space left on device")
return false
}
}
15 changes: 15 additions & 0 deletions SF50 TOLDUITests/Pages/BasePage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ class BasePage {
return scrollToElement(element)?.label ?? ""
}

/// Polls until the element exists and is hittable, then returns whether it is.
///
/// Use for elements presented behind an animation (menu/picker options),
/// whose frame is briefly invalid before presentation settles — tapping
/// during that window fails with "Activation point invalid".
@discardableResult
func waitForHittable(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if element.exists, element.isHittable { return true }
Thread.sleep(forTimeInterval: 0.2)
}
return element.exists && element.isHittable
}

func clearAndType(_ element: XCUIElement, _ text: String) {
element.clearAndType(text, app: app)
}
Expand Down
4 changes: 4 additions & 0 deletions SF50 TOLDUITests/Pages/NOTAMPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ final class NOTAMPage: BasePage {
ensureHittable(contaminationTypePicker)
forceTap(contaminationTypePicker)
let option = app.buttons[type]
XCTAssertTrue(
waitForHittable(option, timeout: 5),
"Contamination option \"\(type)\" should be hittable after opening the picker"
)
forceTap(option)
}
}
Expand Down
23 changes: 21 additions & 2 deletions SF50 TOLDUITests/Pages/ScenarioDetailPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,31 @@ final class ScenarioDetailPage: BasePage {

func setOATDelta(_ value: String) {
XCTAssertTrue(OATDeltaField.exists, "OAT delta field should exist")
OATDeltaField.clearAndType(value, app: app)
clearTypeAndVerify(OATDeltaField, value)
}

func setWeightDelta(_ value: String) {
XCTAssertTrue(weightDeltaField.exists, "Weight delta field should exist")
weightDeltaField.clearAndType(value, app: app)
clearTypeAndVerify(weightDeltaField, value)
}

/// Types `value` into a numeric ``MeasurementField`` and verifies the
/// committed value reflects the typed digits, retrying if a keystroke was
/// dropped (intermittent on slower simulator configs). Ends editing first
/// so the `FormatStyle`-backed field commits before it is read back.
private func clearTypeAndVerify(_ field: XCUIElement, _ value: String, retries: Int = 3) {
let digits = value.filter(\.isNumber)
for attempt in 1...retries {
field.clearAndType(value, app: app)
dismissKeyboard()
let shown = (field.value as? String ?? "").filter(\.isNumber)
if shown.contains(digits) { return }
XCTAssertNotEqual(
attempt,
retries,
"Field did not accept \"\(value)\"; shows \"\(field.value as? String ?? "")\""
)
}
}

func goBack() {
Expand Down
Loading