feat: 支持模拟器定位与地图 fallback
This commit is contained in:
204
suixinkanTests/CoreLocationProviderTests.swift
Normal file
204
suixinkanTests/CoreLocationProviderTests.swift
Normal file
@ -0,0 +1,204 @@
|
||||
//
|
||||
// CoreLocationProviderTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// Core Location fallback 的授权、定位与逆地理编码测试。
|
||||
@MainActor
|
||||
final class CoreLocationProviderTests: XCTestCase {
|
||||
|
||||
func testPrivacyAgreementIsRequiredBeforeLocation() async {
|
||||
let context = makeContext(privacyAccepted: false)
|
||||
|
||||
await assertLocationError(.privacyNotAccepted) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 0)
|
||||
}
|
||||
|
||||
func testDeniedAuthorizationReturnsPermissionError() async {
|
||||
let context = makeContext(authorizationStatus: .denied)
|
||||
|
||||
await assertLocationError(.permissionDenied) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 0)
|
||||
}
|
||||
|
||||
func testNotDeterminedAuthorizationContinuesAfterGrant() async throws {
|
||||
let coordinate = CLLocationCoordinate2D(latitude: 30.2741, longitude: 120.1551)
|
||||
let context = makeContext(
|
||||
authorizationStatus: .notDetermined,
|
||||
requestedAuthorizationStatus: .authorizedWhenInUse,
|
||||
locationResult: .success(CLLocation(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude
|
||||
))
|
||||
)
|
||||
|
||||
let result = try await context.provider.requestCoordinate(
|
||||
desiredAccuracy: kCLLocationAccuracyHundredMeters
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.manager.requestAuthorizationCallCount, 1)
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 1)
|
||||
XCTAssertEqual(context.manager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
|
||||
XCTAssertEqual(result.latitude, coordinate.latitude, accuracy: 0.000_001)
|
||||
XCTAssertEqual(result.longitude, coordinate.longitude, accuracy: 0.000_001)
|
||||
}
|
||||
|
||||
func testLocationFailureUsesExistingBusinessError() async {
|
||||
let context = makeContext(locationResult: .failure)
|
||||
|
||||
await assertLocationError(.locationFailed) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshotContainsReverseGeocodedAddress() async throws {
|
||||
let context = makeContext(
|
||||
locationResult: .success(CLLocation(latitude: 31.2304, longitude: 121.4737)),
|
||||
geocodedAddress: "上海市黄浦区"
|
||||
)
|
||||
|
||||
let snapshot = try await context.provider.requestSnapshot()
|
||||
|
||||
XCTAssertEqual(snapshot.latitude, 31.2304, accuracy: 0.000_001)
|
||||
XCTAssertEqual(snapshot.longitude, 121.4737, accuracy: 0.000_001)
|
||||
XCTAssertEqual(snapshot.address, "上海市黄浦区")
|
||||
XCTAssertEqual(context.geocoder.requestedCoordinates.count, 1)
|
||||
}
|
||||
|
||||
func testSnapshotFallsBackToEmptyAddressWhenGeocoderHasNoResult() async throws {
|
||||
let context = makeContext(
|
||||
locationResult: .success(CLLocation(latitude: 39.9042, longitude: 116.4074)),
|
||||
geocodedAddress: ""
|
||||
)
|
||||
|
||||
let snapshot = try await context.provider.requestSnapshot()
|
||||
|
||||
XCTAssertEqual(snapshot.address, "")
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
privacyAccepted: Bool = true,
|
||||
authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse,
|
||||
requestedAuthorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse,
|
||||
locationResult: FakeCoreLocationManager.LocationResult = .success(
|
||||
CLLocation(latitude: 39.9042, longitude: 116.4074)
|
||||
),
|
||||
geocodedAddress: String = "北京市东城区"
|
||||
) -> TestContext {
|
||||
let suiteName = "CoreLocationProviderTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
let appStore = AppStore(defaults: defaults)
|
||||
appStore.session.privacyAgreementAccepted = privacyAccepted
|
||||
|
||||
let manager = FakeCoreLocationManager(
|
||||
authorizationStatus: authorizationStatus,
|
||||
requestedAuthorizationStatus: requestedAuthorizationStatus,
|
||||
locationResult: locationResult
|
||||
)
|
||||
let geocoder = FakeLocationAddressGeocoder(address: geocodedAddress)
|
||||
let provider = CoreLocationProvider(
|
||||
appStore: appStore,
|
||||
manager: manager,
|
||||
geocoder: geocoder
|
||||
)
|
||||
return TestContext(provider: provider, manager: manager, geocoder: geocoder)
|
||||
}
|
||||
|
||||
private func assertLocationError(
|
||||
_ expected: LocationProviderError,
|
||||
operation: () async throws -> Void
|
||||
) async {
|
||||
do {
|
||||
try await operation()
|
||||
XCTFail("Expected \(expected)")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? LocationProviderError, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Core Location fallback 测试依赖集合。
|
||||
@MainActor
|
||||
private struct TestContext {
|
||||
let provider: CoreLocationProvider
|
||||
let manager: FakeCoreLocationManager
|
||||
let geocoder: FakeLocationAddressGeocoder
|
||||
}
|
||||
|
||||
/// 可控的 `CLLocationManager` 替身。
|
||||
@MainActor
|
||||
private final class FakeCoreLocationManager: CoreLocationManaging {
|
||||
|
||||
/// 单次定位请求的模拟结果。
|
||||
enum LocationResult {
|
||||
case success(CLLocation)
|
||||
case failure
|
||||
}
|
||||
|
||||
var authorizationStatus: CLAuthorizationStatus
|
||||
var desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
|
||||
weak var delegate: CLLocationManagerDelegate?
|
||||
private(set) var requestAuthorizationCallCount = 0
|
||||
private(set) var requestLocationCallCount = 0
|
||||
|
||||
private let requestedAuthorizationStatus: CLAuthorizationStatus
|
||||
private let locationResult: LocationResult
|
||||
|
||||
init(
|
||||
authorizationStatus: CLAuthorizationStatus,
|
||||
requestedAuthorizationStatus: CLAuthorizationStatus,
|
||||
locationResult: LocationResult
|
||||
) {
|
||||
self.authorizationStatus = authorizationStatus
|
||||
self.requestedAuthorizationStatus = requestedAuthorizationStatus
|
||||
self.locationResult = locationResult
|
||||
}
|
||||
|
||||
func requestWhenInUseAuthorization() {
|
||||
requestAuthorizationCallCount += 1
|
||||
authorizationStatus = requestedAuthorizationStatus
|
||||
delegate?.locationManagerDidChangeAuthorization?(CLLocationManager())
|
||||
}
|
||||
|
||||
func requestLocation() {
|
||||
requestLocationCallCount += 1
|
||||
let callbackManager = CLLocationManager()
|
||||
switch locationResult {
|
||||
case let .success(location):
|
||||
delegate?.locationManager?(callbackManager, didUpdateLocations: [location])
|
||||
case .failure:
|
||||
delegate?.locationManager?(
|
||||
callbackManager,
|
||||
didFailWithError: CLError(.locationUnknown)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 可控的逆地理编码替身。
|
||||
@MainActor
|
||||
private final class FakeLocationAddressGeocoder: LocationAddressGeocoding {
|
||||
private(set) var requestedCoordinates: [CLLocationCoordinate2D] = []
|
||||
private let address: String
|
||||
|
||||
init(address: String) {
|
||||
self.address = address
|
||||
}
|
||||
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
requestedCoordinates.append(CLLocationCoordinate2D(
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
))
|
||||
return address
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
|
||||
Reference in New Issue
Block a user