diff --git a/SF50 TOLD.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SF50 TOLD.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7e88cf4..e5de942 100644 --- a/SF50 TOLD.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SF50 TOLD.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/RISCfuture/StreamingLZMA", "state" : { - "revision" : "3773bb684557d2cca47ef29bd04298291a4f7204", - "version" : "1.1.0" + "revision" : "17a9d98a56bdbae7c163f2b43460e19003ed39e3", + "version" : "1.2.0" } }, { diff --git a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift index bfc849f..e0f9e9f 100644 --- a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift +++ b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoader.swift @@ -116,6 +116,8 @@ actor NavDataLoader { self.state = .loading(progress: Float(airportCount + obstaclesProcessed) / Float(totalItems)) } + try writeCycles(nasr.cycles) + state = .finished return LoadResult( cycles: nasr.cycles, @@ -123,6 +125,37 @@ actor NavDataLoader { ) } + /// 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) diff --git a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift index e58731f..c364506 100644 --- a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift +++ b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift @@ -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 { + 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 { + 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) { @@ -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) @@ -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 } diff --git a/SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift b/SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift index 6d62187..ed188cf 100644 --- a/SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift +++ b/SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift @@ -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) { @@ -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 } } diff --git a/SF50 TOLDUITests/Pages/BasePage.swift b/SF50 TOLDUITests/Pages/BasePage.swift index 9f7783b..da4a040 100644 --- a/SF50 TOLDUITests/Pages/BasePage.swift +++ b/SF50 TOLDUITests/Pages/BasePage.swift @@ -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) } diff --git a/SF50 TOLDUITests/Pages/NOTAMPage.swift b/SF50 TOLDUITests/Pages/NOTAMPage.swift index 7c571fd..bf56120 100644 --- a/SF50 TOLDUITests/Pages/NOTAMPage.swift +++ b/SF50 TOLDUITests/Pages/NOTAMPage.swift @@ -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) } } diff --git a/SF50 TOLDUITests/Pages/ScenarioDetailPage.swift b/SF50 TOLDUITests/Pages/ScenarioDetailPage.swift index 1ebd8b4..60536d2 100644 --- a/SF50 TOLDUITests/Pages/ScenarioDetailPage.swift +++ b/SF50 TOLDUITests/Pages/ScenarioDetailPage.swift @@ -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() {