diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 514cca5..339c21f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 26.0.1 # 26.1 is missing simulators on GHA as of November 2025 + xcode-version: latest - uses: actions/checkout@v4 - name: Build & Test run: set -o pipefail && xcodebuild test -scheme 'GeoMonitor' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' | xcbeautify diff --git a/Package.swift b/Package.swift index 36b912c..f2bea22 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.5 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,8 @@ import PackageDescription let package = Package( name: "GeoMonitor", platforms: [ - .iOS(.v13) + .iOS(.v16), + .macOS(.v11) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Sources/GeoMonitor/GeoMonitor.swift b/Sources/GeoMonitor/GeoMonitor.swift index b4edf09..8bd79b4 100644 --- a/Sources/GeoMonitor/GeoMonitor.swift +++ b/Sources/GeoMonitor/GeoMonitor.swift @@ -3,8 +3,7 @@ import Foundation import CoreLocation import MapKit -@available(iOS 14.0, *) -public protocol GeoMonitorDataSource { +@MainActor public protocol GeoMonitorDataSource { func fetchRegions(trigger: GeoMonitor.FetchTrigger) async -> [CLCircularRegion] } @@ -17,20 +16,21 @@ public protocol GeoMonitorDataSource { /// alerted, when they get to them (e.g., traffic incidents); where monitoring can be long-term. /// - Monitoring a set of regions where the user wants to be alerted as they approach them, but /// monitoring is limited for brief durations (e.g., "get off here" alerts for transit apps) -@available(iOS 14.0, *) @MainActor public class GeoMonitor: NSObject, ObservableObject { - enum Constants { - static var currentLocationRegionMaximumRadius: CLLocationDistance = 2_500 - static var currentLocationRegionRadiusDelta: CLLocationDistance = 2_000 - static var maximumDistanceToRegionCenter: CLLocationDistance = 10_000 - static var maximumDistanceForPriorityPruningCenter: CLLocationDistance = 5_000 - static var currentLocationFetchTimeOut: TimeInterval = 30 - static var currentLocationFetchRecency: TimeInterval = 10 - static var minIntervalBetweenEnteringSameRegion: TimeInterval = 120 + public struct Config: Sendable { + public static let `default` = Config() + + public var currentLocationRegionMaximumRadius: CLLocationDistance = 2_500 + public var currentLocationRegionRadiusDelta: CLLocationDistance = 2_000 + public var maximumDistanceToRegionCenter: CLLocationDistance = 10_000 + public var maximumDistanceForPriorityPruningCenter: CLLocationDistance = 5_000 + public var currentLocationFetchTimeOut: TimeInterval = 30 + public var currentLocationFetchRecency: TimeInterval = 10 + public var minIntervalBetweenEnteringSameRegion: TimeInterval = 120 } - public enum FetchTrigger: String { + public enum FetchTrigger: String, Sendable { case manual case initial case visitMonitoring @@ -84,14 +84,16 @@ public class GeoMonitor: NSObject, ObservableObject { /// Set to `true` if the `hasAccuracy` values should also check whether the user /// has provided access to the full accuracy/ public var needsFullAccuracy: Bool = false + + private let config: Config /// Instantiates new monitor /// - Parameters: /// - enabledKey: User defaults key to use store whether background tracking should be enabled /// - fetch: Handler that's called when the monitor decides it's a good time to update the regions to monitor. Should fetch and then return all regions to be monitored (even if they didn't change). /// - onEvent: Handler that's called when a relevant event is happening, including when one of the monitored regions is entered. - public convenience init(enabledKey: String? = nil, fetch: @escaping (GeoMonitor.FetchTrigger) async -> [CLCircularRegion], onEvent: @escaping (Event) -> Void) { - self.init(enabledKey: enabledKey, dataSource: SimpleDataSource(handler: fetch), onEvent: onEvent) + public convenience init(enabledKey: String? = nil, config: Config = .default, fetch: @escaping (GeoMonitor.FetchTrigger) async -> [CLCircularRegion], onEvent: @escaping (Event) -> Void) { + self.init(enabledKey: enabledKey, dataSource: SimpleDataSource(handler: fetch), config: config, onEvent: onEvent) } /// Instantiates new monitor @@ -99,10 +101,11 @@ public class GeoMonitor: NSObject, ObservableObject { /// - enabledKey: User defaults key to use store whether background tracking should be enabled /// - dataSource: Data source that provides regions. Will be maintained strongly. /// - onEvent: Handler that's called when a relevant event is happening, including when one of the monitored regions is entered. - public init(enabledKey: String? = nil, dataSource: GeoMonitorDataSource, onEvent: @escaping (Event) -> Void) { + public init(enabledKey: String? = nil, dataSource: GeoMonitorDataSource, config: Config = .default, onEvent: @escaping (Event) -> Void) { fetchSource = dataSource eventHandler = onEvent locationManager = .init() + self.config = config hasAccess = false self.enabledKey = enabledKey if let enabledKey = enabledKey { @@ -130,14 +133,7 @@ public class GeoMonitor: NSObject, ObservableObject { /// Whether it's possible to bring up the system prompt to ask for access to the device's location public var canAsk: Bool { - switch locationManager.authorizationStatus { - case .notDetermined: - return true - case .authorizedAlways, .authorizedWhenInUse, .denied, .restricted: - return false - @unknown default: - return false - } + locationManager.authorizationStatus == .notDetermined } private func updateAccess() { @@ -147,9 +143,11 @@ public class GeoMonitor: NSObject, ObservableObject { // Note: We do NOT update `enableInBackground` here, as that's the user's // setting, i.e., they might not want to have it enabled even though the // app has permissions. +#if !os(macOS) case .authorizedWhenInUse: hasAccess = !needsFullAccuracy || locationManager.accuracyAuthorization == .fullAccuracy enableInBackground = false +#endif case .denied, .notDetermined, .restricted: hasAccess = false enableInBackground = false @@ -158,6 +156,19 @@ public class GeoMonitor: NSObject, ObservableObject { enableInBackground = false } } + + private func shouldRequestBackgroundAuthorization(from status: CLAuthorizationStatus) -> Bool { + switch status { + case .notDetermined: + return true +#if !os(macOS) + case .authorizedWhenInUse: + return true +#endif + default: + return false + } + } public func ask(forBackground: Bool = false, _ handler: @escaping (Bool) -> Void = { _ in }) { if forBackground { @@ -177,7 +188,11 @@ public class GeoMonitor: NSObject, ObservableObject { } } else { self.askHandler = handler +#if os(macOS) + locationManager.requestAlwaysAuthorization() +#else locationManager.requestWhenInUseAuthorization() +#endif } } @@ -204,14 +219,14 @@ public class GeoMonitor: NSObject, ObservableObject { private var fetchTimer: Timer? - private func fetchCurrentLocation() async throws -> CLLocation { + public func fetchCurrentLocation() async throws -> CLLocation { guard hasAccess else { throw LocationFetchError.accessNotProvided } let desiredAccuracy = kCLLocationAccuracyHundredMeters if let currentLocation = currentLocation, - currentLocation.timestamp.timeIntervalSinceNow > Constants.currentLocationFetchRecency * -1, + currentLocation.timestamp.timeIntervalSinceNow > config.currentLocationFetchRecency * -1, currentLocation.horizontalAccuracy <= desiredAccuracy { // We have a current location and it's less than 10 seconds old. Just use it return currentLocation @@ -221,7 +236,7 @@ public class GeoMonitor: NSObject, ObservableObject { locationManager.desiredAccuracy = desiredAccuracy locationManager.requestLocation() - fetchTimer = .scheduledTimer(withTimeInterval: Constants.currentLocationFetchTimeOut, repeats: false) { [weak self] _ in + fetchTimer = .scheduledTimer(withTimeInterval: config.currentLocationFetchTimeOut, repeats: false) { [weak self] _ in Task { [weak self] in await self?.notify(.failure(LocationFetchError.noLocationFetchedInTime)) } @@ -253,7 +268,7 @@ public class GeoMonitor: NSObject, ObservableObject { @Published public var enableInBackground: Bool = false { didSet { guard enableInBackground != oldValue else { return } - if enableInBackground, (locationManager.authorizationStatus == .notDetermined || locationManager.authorizationStatus == .authorizedWhenInUse) { + if enableInBackground, shouldRequestBackgroundAuthorization(from: locationManager.authorizationStatus) { ask(forBackground: true) } else if enableInBackground { updateAccess() @@ -346,7 +361,6 @@ public class GeoMonitor: NSObject, ObservableObject { // MARK: - Trigger on move -@available(iOS 14.0, *) extension GeoMonitor { @discardableResult @@ -379,11 +393,11 @@ extension GeoMonitor { center: location.coordinate, radius: // "In iOS 6, regions with a radius between 1 and 400 meters work better on iPhone 4S or later devices. " - min(Constants.currentLocationRegionMaximumRadius, + min(config.currentLocationRegionMaximumRadius, // "This property defines the largest boundary distance allowed from a region’s center point. Attempting to monitor a region with a distance larger than this value causes the location manager to send a CLError.Code.regionMonitoringFailure error to the delegate." min(self.locationManager.maximumRegionMonitoringDistance, - location.horizontalAccuracy + Constants.currentLocationRegionRadiusDelta + location.horizontalAccuracy + config.currentLocationRegionRadiusDelta ) ), identifier: "current-location" @@ -406,7 +420,6 @@ extension GeoMonitor { // MARK: - Alert monitoring logic -@available(iOS 14.0, *) extension GeoMonitor { private func monitorDebounced(_ regions: [CLCircularRegion], location: CLLocation?, delay: TimeInterval? = nil) { @@ -437,14 +450,19 @@ extension GeoMonitor { let currentLocation = location ?? self.currentLocation - let toMonitor = Self.determineRegionsToMonitor( + let max = maxRegionsToMonitor - 1 // keep one for current location + let analyzed = Self.determineRegionsToMonitor( regions: regions, location: currentLocation, - max: maxRegionsToMonitor - 1 // keep one for current location + max: max, + config: config ) + let toMonitor = analyzed + .filter(\.keep) + .prefix(max) // Stop monitoring regions that are no irrelevant - let toMonitorIDs = Set(toMonitor.map(\.identifier)) + let toMonitorIDs = Set(toMonitor.map(\.region.identifier)) var removedCount: Int = 0 for previous in locationManager.monitoredRegions { if !toMonitorIDs.contains(previous.identifier) && previous.identifier != "current-location" { @@ -456,52 +474,74 @@ extension GeoMonitor { // Start monitoring those we need to monitor let monitoredAlready = locationManager.monitoredRegions.map(\.identifier) let newRegion = toMonitor - .filter { !monitoredAlready.contains($0.identifier) } + .filter { !monitoredAlready.contains($0.region.identifier) } newRegion + .map(\.region) .forEach(locationManager.startMonitoring(for:)) - eventHandler(.status("Updating monitored regions. \(regions.count) candidates; monitoring \(toMonitor.count) regions; removed \(removedCount), kept \(monitoredAlready.count), added \(newRegion.count); now monitoring \(locationManager.monitoredRegions.count).", .updatingMonitoredRegions)) + let furthestMonitored = toMonitor.compactMap(\.distance).max() + eventHandler(.status("Updating monitored regions. \(regions.count) candidates; monitoring \(toMonitor.count) regions; removed \(removedCount), kept \(monitoredAlready.count), added \(newRegion.count); now monitoring \(locationManager.monitoredRegions.count). Furthest is \(furthestMonitored ?? -1).", .updatingMonitoredRegions)) + } + + struct AnalyzedRegion { + let region: CLCircularRegion + let distance: CLLocationDistance? + let priority: Int? + var keep: Bool } @MainActor - static func determineRegionsToMonitor(regions: [CLCircularRegion], location: CLLocation?, max: Int) -> [CLCircularRegion] { - - let processed: [(CLCircularRegion, distance: CLLocationDistance?, priority: Int?)] = regions.map { region in + static func determineRegionsToMonitor(regions: [CLCircularRegion], location: CLLocation?, max: Int, config: Config) -> [AnalyzedRegion] { + let processed: [AnalyzedRegion] = regions.map { region in let distance = location.map { $0.distance(from: .init(latitude: region.center.latitude, longitude: region.center.longitude)) } let priority = (region as? PrioritizedRegion)?.priority - return (region, distance: distance, priority: priority) + return .init(region: region, distance: distance, priority: priority, keep: true) } - - // Then effectively monitor the nearest - let nearby = processed.filter { _, distance, _ in - (distance ?? 0) < Constants.maximumDistanceToRegionCenter + + // Mark nearby candidates first; we keep full analysis output and only toggle `keep`. + let nearby = processed.map { analyzed in + var updated = analyzed + updated.keep = (analyzed.distance ?? 0) < config.maximumDistanceToRegionCenter + return updated } - - // The ones to monitor, optionally pruned by either priority or the nearest - guard nearby.count > max else { - return nearby.map(\.0) + + let nearbyCount = nearby.count(where: \.keep) + guard nearbyCount > max else { + return nearby } - let prefix = nearby - .sorted { lhs, rhs in - if let leftDistance = lhs.distance, let rightDistance = rhs.distance, leftDistance > Constants.maximumDistanceForPriorityPruningCenter || rightDistance > Constants.maximumDistanceForPriorityPruningCenter { - return leftDistance < rightDistance - } else if let leftPriority = lhs.priority, let rightPriority = rhs.priority, leftPriority != rightPriority { - return leftPriority > rightPriority - } else { - return lhs.0.identifier < rhs.0.identifier + // If over limit, choose the winning subset and mark only those as keep=true. + let selectedIDs = Set( + nearby + .filter(\.keep) + .sorted { lhs, rhs in + if let leftDistance = lhs.distance, let rightDistance = rhs.distance, + leftDistance > config.maximumDistanceForPriorityPruningCenter || rightDistance > config.maximumDistanceForPriorityPruningCenter { + return leftDistance < rightDistance + } else if let leftPriority = lhs.priority, let rightPriority = rhs.priority, leftPriority != rightPriority { + return leftPriority > rightPriority + } else { + return lhs.region.identifier < rhs.region.identifier + } } + .prefix(max) + .map { $0.region.identifier } + ) + + return nearby.map { analyzed in + var updated = analyzed + if updated.keep { + updated.keep = selectedIDs.contains(updated.region.identifier) } - .prefix(max) - return Array(prefix.map(\.0)) + return updated + } } } // MARK: - CLLocationManagerDelegate -@available(iOS 14.0, *) -extension GeoMonitor: CLLocationManagerDelegate { +extension GeoMonitor: @MainActor CLLocationManagerDelegate { public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { dispatchPrecondition(condition: .onQueue(.main)) @@ -529,7 +569,7 @@ extension GeoMonitor: CLLocationManagerDelegate { do { let location = try await fetchCurrentLocation() - let minInterval = Constants.minIntervalBetweenEnteringSameRegion * -1 + let minInterval = config.minIntervalBetweenEnteringSameRegion * -1 if let lastReport = recentlyReportedRegionIdentifiers.first(where: { $0.0 == region.identifier }), lastReport.1.timeIntervalSinceNow >= minInterval { eventHandler(.status("GeoMonitor reported duplicate for \(region.identifier). Entered \(lastReport.1.timeIntervalSinceNow * -1) seconds ago.", .enteredRegion)) return // Already reported with `minIntervalBetweenEnteringSameRegion` @@ -628,10 +668,16 @@ extension GeoMonitor: CLLocationManagerDelegate { askHandler = { _ in } switch manager.authorizationStatus { - case .authorizedAlways, .authorizedWhenInUse: + case .authorizedAlways: if isMonitoring { startMonitoring() } +#if !os(macOS) + case .authorizedWhenInUse: + if isMonitoring { + startMonitoring() + } +#endif case .denied, .notDetermined, .restricted: return @unknown default: @@ -643,7 +689,6 @@ extension GeoMonitor: CLLocationManagerDelegate { // MARK: - Helpers -@available(iOS 14.0, *) private struct SimpleDataSource: GeoMonitorDataSource { let handler: (GeoMonitor.FetchTrigger) async -> [CLCircularRegion] diff --git a/Tests/GeoMonitorTests/GeoMonitorTests.swift b/Tests/GeoMonitorTests/GeoMonitorTests.swift index 1bca6f1..09dcf27 100644 --- a/Tests/GeoMonitorTests/GeoMonitorTests.swift +++ b/Tests/GeoMonitorTests/GeoMonitorTests.swift @@ -1,15 +1,14 @@ -import XCTest +import Foundation import CoreLocation +import Testing @testable import GeoMonitor -@available(iOS 14.0, *) -final class GeoMonitorTests: XCTestCase { - func testManyRegions() async throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - +@Suite("GeoMonitor") +struct GeoMonitorTests { + @Test + @MainActor + func manyRegions() { let regions: [PrioritizedRegion] = [ .init(-31.959492, 115.87516, 900, 400), .init(-31.953156, 115.877762, 900, 400), @@ -59,66 +58,70 @@ final class GeoMonitorTests: XCTestCase { .init(-31.951407, 115.861664, 172, 150), .init(-31.943138, 115.854218, 295, 150), .init(-31.943686, 115.922653, 194, 150), - .init(-31.873867, 115.76548, 151, 150), - .init(-31.943686, 115.922653, 226, 150), - .init(-31.9938, 115.913, 127, 150), - .init(-31.943686, 115.922653, 217, 150), - .init(-31.873867, 115.76548, 175, 150), - .init(-31.951407, 115.861664, 241, 150), - .init(-31.899336, 115.971687, 212, 150), - .init(-31.913763, 115.823273, 315, 150), - .init(-31.957066, 115.859146, 168, 150), - .init(-31.907438, 115.821877, 817, 150), - .init(-31.953903, 115.8945, 425, 150), - .init(-31.940687, 116.015968, 127, 150), - .init(-31.947477, 115.878456, 235, 150), - .init(-31.940687, 116.015968, 130, 150), - .init(-31.958574, 115.858421, 451, 150), - .init(-31.907438, 115.821877, 799, 150), - .init(-31.907438, 115.821877, 804, 150), - .init(-31.947477, 115.878456, 234, 150), - .init(-31.947477, 115.878456, 223, 150), - .init(-31.947477, 115.878456, 230, 150), - .init(-31.947477, 115.878456, 234, 150), - .init(-31.96996, 115.893616, 122, 150), - .init(-31.96996, 115.893616, 122, 150), - .init(-31.873867, 115.76548, 158, 150), - .init(-31.913763, 115.823273, 400,150), + .init(-31.873867, 115.76548, 151, 150), + .init(-31.943686, 115.922653, 226, 150), + .init(-31.9938, 115.913, 127, 150), + .init(-31.943686, 115.922653, 217, 150), + .init(-31.873867, 115.76548, 175, 150), + .init(-31.951407, 115.861664, 241, 150), + .init(-31.899336, 115.971687, 212, 150), + .init(-31.913763, 115.823273, 315, 150), + .init(-31.957066, 115.859146, 168, 150), + .init(-31.907438, 115.821877, 817, 150), + .init(-31.953903, 115.8945, 425, 150), + .init(-31.940687, 116.015968, 127, 150), + .init(-31.947477, 115.878456, 235, 150), + .init(-31.940687, 116.015968, 130, 150), + .init(-31.958574, 115.858421, 451, 150), + .init(-31.907438, 115.821877, 799, 150), + .init(-31.907438, 115.821877, 804, 150), + .init(-31.947477, 115.878456, 234, 150), + .init(-31.947477, 115.878456, 223, 150), + .init(-31.947477, 115.878456, 230, 150), + .init(-31.947477, 115.878456, 234, 150), + .init(-31.96996, 115.893616, 122, 150), + .init(-31.96996, 115.893616, 122, 150), + .init(-31.873867, 115.76548, 158, 150), + .init(-31.913763, 115.823273, 400, 150), .init(-31.8883, 115.801453, 148, 150), - .init(-31.907438, 115.821877, 672, 150), - .init(-31.953903, 115.8945, 280, 150), - .init(-31.943138, 115.854218, 291, 150), - .init(-31.96996, 115.893616, 126, 150), - .init(-31.907438, 115.821877, 529, 150), - .init(-31.953903, 115.8945, 137, 150), - .init(-31.958574, 115.858421, 357, 150), - .init(-31.958574, 115.858421, 511, 150), - .init(-31.958574, 115.858421, 508, 150), - .init(-31.958574, 115.858421, 533, 150), - .init(-32.012253, 115.856537, 145, 150), - .init(-32.012253, 115.856537, 184, 150), - .init(-31.907438, 115.821877, 827, 150), - .init(-31.953903, 115.8945, 435, 150), - .init(-31.940687, 116.015968, 191, 150), - .init(-31.940687, 116.015968, 156, 150), - .init(-31.958574, 115.858421, 486, 150), + .init(-31.907438, 115.821877, 672, 150), + .init(-31.953903, 115.8945, 280, 150), + .init(-31.943138, 115.854218, 291, 150), + .init(-31.96996, 115.893616, 126, 150), + .init(-31.907438, 115.821877, 529, 150), + .init(-31.953903, 115.8945, 137, 150), + .init(-31.958574, 115.858421, 357, 150), + .init(-31.958574, 115.858421, 511, 150), + .init(-31.958574, 115.858421, 508, 150), + .init(-31.958574, 115.858421, 533, 150), + .init(-32.012253, 115.856537, 145, 150), + .init(-32.012253, 115.856537, 184, 150), + .init(-31.907438, 115.821877, 827, 150), + .init(-31.953903, 115.8945, 435, 150), + .init(-31.940687, 116.015968, 191, 150), + .init(-31.940687, 116.015968, 156, 150), + .init(-31.958574, 115.858421, 486, 150), ] let needle = CLLocation(latitude: -31.9586, longitude: 115.8681) - let withoutLocation = await GeoMonitor.determineRegionsToMonitor(regions: regions, location: nil, max: 19) - XCTAssertEqual(withoutLocation.count, 19) - XCTAssertFalse(withoutLocation.allSatisfy { needle.distance(from: .init(latitude: $0.center.latitude, longitude: $0.center.longitude)) <= 5_000 }) - XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).min() ?? 0, 529) // highest priorities - XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).max(), 900) - XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.filter { $0.priority == 900 }.count, 8) // all top priorities included - - let withLocation = await GeoMonitor.determineRegionsToMonitor(regions: regions, location: needle, max: 19) - XCTAssertEqual(withLocation.count, 19) - XCTAssertTrue(withLocation.allSatisfy { needle.distance(from: .init(latitude: $0.center.latitude, longitude: $0.center.longitude)) <= 5_000 }) - XCTAssertEqual(withLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).min() ?? 0, 349) // highest priorities - XCTAssertEqual(withLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).max(), 900) - XCTAssertEqual(withLocation.compactMap { $0 as? PrioritizedRegion }.filter { $0.priority == 900 }.count, 8) // all top priorities included + let withoutLocation = GeoMonitor.determineRegionsToMonitor(regions: regions, location: nil, max: 19, config: .default) + let withoutLocationKept = withoutLocation.filter(\.keep) + #expect(withoutLocation.count == regions.count) + #expect(withoutLocationKept.count == 19) + #expect(!withoutLocationKept.allSatisfy { needle.distance(from: .init(latitude: $0.region.center.latitude, longitude: $0.region.center.longitude)) <= 5_000 }) + #expect((withoutLocationKept.compactMap(\.priority).min() ?? 0) == 529) + #expect((withoutLocationKept.compactMap(\.priority).max() ?? 0) == 900) + #expect(withoutLocationKept.filter { $0.priority == 900 }.count == 8) + + let withLocation = GeoMonitor.determineRegionsToMonitor(regions: regions, location: needle, max: 19, config: .default) + let withLocationKept = withLocation.filter(\.keep) + #expect(withLocation.count == regions.count) + #expect(withLocationKept.count == 19) + #expect(withLocationKept.allSatisfy { needle.distance(from: .init(latitude: $0.region.center.latitude, longitude: $0.region.center.longitude)) <= 5_000 }) + #expect((withLocationKept.compactMap(\.priority).min() ?? 0) == 349) + #expect((withLocationKept.compactMap(\.priority).max() ?? 0) == 900) + #expect(withLocationKept.filter { $0.priority == 900 }.count == 8) } }