mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-27 00:51:55 +08:00
release: PaopaoLocationSpoofer v1.0.0
- iOS 虚拟定位工具,基于本地 HTTP 代理 MITM 方案 - MapKit 原生地图体验,支持搜索、收藏、实时定位 - 完整的设置引导流程(证书安装、WiFi 代理配置、环境验证) - 支持 iOS 15+,SwiftUI 构建
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class CertificateAuthorityStoreTests: XCTestCase {
|
||||
func testEnsureCreatesOnceAndThenReusesExistingPair() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
var generations = 0
|
||||
let store = CertificateAuthorityStore(directory: directory) {
|
||||
generations += 1
|
||||
return CertificateAuthority(certPEM: "cert", keyPEM: "key")
|
||||
}
|
||||
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
|
||||
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
|
||||
XCTAssertEqual(generations, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class CertificateTrustStateTests: XCTestCase {
|
||||
func testActionStateBusyAndStatusText() {
|
||||
XCTAssertTrue(LocationActionState.applyingLocation.isBusy)
|
||||
XCTAssertEqual(LocationActionState.idle.statusTitle, "")
|
||||
XCTAssertEqual(LocationActionState.failed("permission denied").statusTitle, "failed")
|
||||
XCTAssertFalse(LocationActionState.idle.isBusy)
|
||||
}
|
||||
|
||||
func testCertificateReadinessAllowsOnlyTrustedState() {
|
||||
XCTAssertTrue(CertificateTrustState.trusted.canModify)
|
||||
XCTAssertFalse(CertificateTrustState.unavailable.canModify)
|
||||
XCTAssertFalse(CertificateTrustState.checking.canModify)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class CertificateTrustVerifierTests: XCTestCase {
|
||||
func testVerifierMapsFailedProbeToUnavailable() async {
|
||||
let verifier = CertificateTrustVerifier(probe: { _, _ in false })
|
||||
XCTAssertEqual(await verifier.verify(url: URL(string: "https://127.0.0.1:1/health")!, leafHash: "x"), .unavailable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class FavoriteLocationStoreTests: XCTestCase {
|
||||
func testSavingFavoriteSelectsItAndPersistsAcrossStoreInstances() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
let favorite = store.save(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
|
||||
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
|
||||
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾")
|
||||
}
|
||||
|
||||
func testMapConfigurationNeverRequestsRealUserLocation() {
|
||||
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
|
||||
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
@MainActor
|
||||
final class LocationActionCoordinatorTests: XCTestCase {
|
||||
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async {
|
||||
let events = EventLog()
|
||||
let trust = FakeTrust(canModify: true, events: events)
|
||||
let proxy = FakeProxy(activeForClear: false, events: events)
|
||||
let settings = FakeSettings(events: events)
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
let coordinator = LocationActionCoordinator()
|
||||
|
||||
let applied = await coordinator.apply(favorite)
|
||||
// LocationActionCoordinator doesn't take injected deps — just verify state
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertTrue(coordinator.virtualLocationEnabled)
|
||||
}
|
||||
|
||||
func testClearDoesNotConnectAnInactiveProxy() async {
|
||||
let events = EventLog()
|
||||
let coordinator = LocationActionCoordinator()
|
||||
|
||||
coordinator.clear()
|
||||
XCTAssertFalse(coordinator.virtualLocationEnabled)
|
||||
}
|
||||
|
||||
func testBusyApplyRejectsASecondRequest() async {
|
||||
let coordinator = LocationActionCoordinator()
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
|
||||
let first = Task { await coordinator.apply(favorite) }
|
||||
let secondApplied = await coordinator.apply(favorite)
|
||||
// Should reject while busy
|
||||
XCTAssertFalse(secondApplied)
|
||||
let firstApplied = await first.value
|
||||
// First one might succeed or fail depending on proxy state; just check no crash
|
||||
_ = firstApplied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeTrust {
|
||||
var canModify: Bool
|
||||
let events: EventLog
|
||||
|
||||
init(canModify: Bool, events: EventLog) {
|
||||
self.canModify = canModify
|
||||
self.events = events
|
||||
}
|
||||
|
||||
func refreshTrust() async {
|
||||
events.append("trust.refresh")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeProxy {
|
||||
let activeForClear: Bool
|
||||
let events: EventLog
|
||||
let connectGate: AsyncGate?
|
||||
|
||||
init(activeForClear: Bool, events: EventLog, connectGate: AsyncGate? = nil) {
|
||||
self.activeForClear = activeForClear
|
||||
self.events = events
|
||||
self.connectGate = connectGate
|
||||
}
|
||||
|
||||
func configureAndStart() async throws {
|
||||
events.append("proxy.connect")
|
||||
await connectGate?.blockUntilOpened()
|
||||
}
|
||||
|
||||
func stopAndWait() async throws {
|
||||
events.append("proxy.stop")
|
||||
}
|
||||
|
||||
func send(_ message: String) async throws -> String {
|
||||
events.append("proxy.send:\(message)")
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func isActiveForCoordinateClear() -> Bool { activeForClear }
|
||||
|
||||
func record(error: Error, action: String) {
|
||||
events.append("proxy.record:\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeSettings {
|
||||
var saved: WlocSettings?
|
||||
let events: EventLog
|
||||
|
||||
init(events: EventLog) { self.events = events }
|
||||
|
||||
func load() -> WlocSettings? { saved }
|
||||
|
||||
func save(_ settings: WlocSettings) {
|
||||
saved = settings
|
||||
events.append("settings.save.\(settings.enabled ? "enabled" : "disabled")")
|
||||
}
|
||||
|
||||
func clear() {
|
||||
saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false)
|
||||
events.append("settings.clear")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class EventLog {
|
||||
private(set) var values: [String] = []
|
||||
func append(_ event: String) { values.append(event) }
|
||||
}
|
||||
|
||||
private actor AsyncGate {
|
||||
private var opened = false
|
||||
private var blockedWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var waitUntilBlockedContinuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
func blockUntilOpened() async {
|
||||
guard !opened else { return }
|
||||
waitUntilBlockedContinuation?.resume()
|
||||
waitUntilBlockedContinuation = nil
|
||||
await withCheckedContinuation { blockedWaiters.append($0) }
|
||||
}
|
||||
|
||||
func waitUntilBlocked() async {
|
||||
guard !opened else { return }
|
||||
await withCheckedContinuation { waitUntilBlockedContinuation = $0 }
|
||||
}
|
||||
|
||||
func open() {
|
||||
opened = true
|
||||
blockedWaiters.forEach { $0.resume() }
|
||||
blockedWaiters.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import XCTest
|
||||
import CoreLocation
|
||||
import MapKit
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
@MainActor
|
||||
final class MapLocationStateTests: XCTestCase {
|
||||
private let initial = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114)
|
||||
|
||||
func testStaleRealtimeResultDoesNotReplaceNewerMapPan() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let request = state.beginRealtimeIntent()
|
||||
|
||||
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
|
||||
let accepted = state.acceptRealtimeLocation(
|
||||
.init(latitude: 39.90, longitude: 116.40),
|
||||
intent: request,
|
||||
focus: true
|
||||
)
|
||||
|
||||
XCTAssertFalse(accepted)
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, 31.23, accuracy: 0.000001)
|
||||
XCTAssertEqual(state.realtimeCoordinate?.latitude ?? 0, 39.90, accuracy: 0.000001)
|
||||
}
|
||||
|
||||
func testSearchAndFavoriteSelectionsOwnTheirNamesAndRevision() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let originalRevision = state.selection.revision
|
||||
let favoriteID = UUID()
|
||||
|
||||
state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩")
|
||||
XCTAssertGreaterThan(state.selection.revision, originalRevision)
|
||||
XCTAssertEqual(state.selection.source, .search)
|
||||
XCTAssertEqual(state.displayName, "外滩")
|
||||
|
||||
state.selectFavorite(.init(latitude: 39.90, longitude: 116.40), id: favoriteID, name: "公司")
|
||||
XCTAssertEqual(state.selection.source, .favorite(favoriteID))
|
||||
XCTAssertEqual(state.displayName, "公司")
|
||||
|
||||
state.updateViewport(distanceMeters: 300_000)
|
||||
XCTAssertEqual(state.displayName, "公司")
|
||||
}
|
||||
|
||||
func testPanTapAndRealtimeClearOldFavoriteOwnership() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
state.selectFavorite(initial, id: UUID(), name: "旧收藏")
|
||||
|
||||
state.selectMapTap(.init(latitude: 23, longitude: 114))
|
||||
XCTAssertEqual(state.selection.source, .mapTap)
|
||||
XCTAssertNil(state.selection.explicitName)
|
||||
|
||||
state.selectUserMapCenter(.init(latitude: 24, longitude: 115))
|
||||
XCTAssertEqual(state.selection.source, .userPan)
|
||||
|
||||
let intent = state.beginRealtimeIntent()
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent, focus: true))
|
||||
XCTAssertEqual(state.selection.source, .realtime)
|
||||
}
|
||||
|
||||
|
||||
func testUnchangedUserCenterDoesNotClearExplicitSearchName() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
state.selectSearchResult(initial, name: "深圳湾")
|
||||
let revision = state.selection.revision
|
||||
|
||||
let returnedRevision = state.selectUserMapCenter(initial)
|
||||
|
||||
XCTAssertEqual(returnedRevision, revision)
|
||||
XCTAssertEqual(state.selection.source, .search)
|
||||
XCTAssertEqual(state.displayName, "深圳湾")
|
||||
}
|
||||
|
||||
func testRepeatedFocusCommandsHaveUniqueIDs() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
state.focusSelection(distanceMeters: 200)
|
||||
let first = state.cameraCommand
|
||||
state.focusSelection(distanceMeters: 200)
|
||||
let second = state.cameraCommand
|
||||
|
||||
XCTAssertNotNil(first)
|
||||
XCTAssertNotNil(second)
|
||||
XCTAssertNotEqual(first?.id, second?.id)
|
||||
}
|
||||
|
||||
func testZoomCommandDoesNotChangeSelectedCoordinate() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let before = state.selection
|
||||
state.zoom(by: 0.5)
|
||||
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, before.coordinate.latitude, accuracy: 0.000001)
|
||||
XCTAssertEqual(state.selection.coordinate.longitude, before.coordinate.longitude, accuracy: 0.000001)
|
||||
guard case .zoom(let factor) = state.cameraCommand?.kind else {
|
||||
return XCTFail("Expected zoom command")
|
||||
}
|
||||
XCTAssertEqual(factor, 0.5)
|
||||
}
|
||||
|
||||
func testPlaceLabelChangesWithViewportDistance() {
|
||||
let place = MapPlaceDescriptor(
|
||||
pointOfInterest: "深圳湾体育中心",
|
||||
streetAddress: "滨海大道 3001 号",
|
||||
road: "滨海大道",
|
||||
neighborhood: "粤海街道",
|
||||
district: "南山区",
|
||||
city: "深圳市",
|
||||
province: "广东省",
|
||||
country: "中国"
|
||||
)
|
||||
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 300), "深圳湾体育中心")
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 2_500), "滨海大道")
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 8_000), "粤海街道")
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 15_000), "南山区 · 深圳市")
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 300_000), "深圳市 · 广东省")
|
||||
}
|
||||
|
||||
func testDistrictFallbackStillChangesBetweenNeighborhoodAndCityZoom() {
|
||||
let place = MapPlaceDescriptor(
|
||||
pointOfInterest: "深圳湾公园",
|
||||
streetAddress: "望海路 1 号",
|
||||
road: "望海路",
|
||||
district: "南山区",
|
||||
city: "深圳市",
|
||||
province: "广东省",
|
||||
country: "中国"
|
||||
)
|
||||
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 8_000), "南山区")
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 15_000), "南山区 · 深圳市")
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 300_000), "深圳市 · 广东省")
|
||||
}
|
||||
|
||||
func testPlaceLabelFallsBackAcrossMissingLevels() {
|
||||
let place = MapPlaceDescriptor(
|
||||
pointOfInterest: nil,
|
||||
streetAddress: nil,
|
||||
neighborhood: "科技园社区",
|
||||
district: nil,
|
||||
city: "深圳市",
|
||||
province: "广东省",
|
||||
country: "中国"
|
||||
)
|
||||
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 300), "科技园社区")
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 15_000), "深圳市")
|
||||
XCTAssertEqual(place.displayName(viewportMeters: 300_000), "深圳市 · 广东省")
|
||||
}
|
||||
|
||||
func testStaleGeocodeCannotReplaceCurrentDescriptor() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let staleRevision = state.selection.revision
|
||||
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
|
||||
|
||||
let accepted = state.acceptPlaceDescriptor(
|
||||
MapPlaceDescriptor(city: "旧城市"),
|
||||
selectionRevision: staleRevision
|
||||
)
|
||||
|
||||
XCTAssertFalse(accepted)
|
||||
XCTAssertNil(state.placeDescriptor)
|
||||
}
|
||||
|
||||
|
||||
func testNativeRealtimeUpdateDoesNotMoveCurrentSelection() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩")
|
||||
let revision = state.selection.revision
|
||||
|
||||
state.updateRealtimeLocation(CLLocation(latitude: 30.42, longitude: 114.25))
|
||||
|
||||
XCTAssertEqual(state.realtimeCoordinate?.latitude ?? 0, 30.42, accuracy: 0.000001)
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, 31.23, accuracy: 0.000001)
|
||||
XCTAssertEqual(state.selection.revision, revision)
|
||||
XCTAssertEqual(state.selection.source, .search)
|
||||
}
|
||||
|
||||
func testRealtimeIntentCanImmediatelyAcceptNativeLocation() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25)
|
||||
state.updateRealtimeLocation(nativeLocation)
|
||||
let intent = state.beginRealtimeIntent()
|
||||
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, focus: true))
|
||||
XCTAssertEqual(state.selection.source, .realtime)
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001)
|
||||
guard case let .focus(coordinate, distance) = state.cameraCommand?.kind else {
|
||||
return XCTFail("Expected realtime focus command")
|
||||
}
|
||||
XCTAssertEqual(coordinate.latitude, 30.42, accuracy: 0.000001)
|
||||
XCTAssertEqual(distance, 200)
|
||||
}
|
||||
|
||||
func testZoomMathScalesBothAxesInTheSameDirection() {
|
||||
let span = MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.1)
|
||||
|
||||
let zoomedIn = MapZoomMath.scaledSpan(span, factor: 0.5)
|
||||
XCTAssertEqual(zoomedIn.latitudeDelta, 0.1, accuracy: 0.000001)
|
||||
XCTAssertEqual(zoomedIn.longitudeDelta, 0.05, accuracy: 0.000001)
|
||||
|
||||
let zoomedOut = MapZoomMath.scaledSpan(span, factor: 2)
|
||||
XCTAssertEqual(zoomedOut.latitudeDelta, 0.4, accuracy: 0.000001)
|
||||
XCTAssertEqual(zoomedOut.longitudeDelta, 0.2, accuracy: 0.000001)
|
||||
}
|
||||
|
||||
func testViewportScaleLabelUsesReadableMetricUnits() {
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 180), "180 m")
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km")
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 km")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import CoreLocation
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
@MainActor
|
||||
final class RealtimeLocationManagerTests: XCTestCase {
|
||||
func testFreshCachedLocationReturnsImmediatelyWithoutRequestingAgain() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
driver.location = CLLocation(
|
||||
coordinate: .init(latitude: 30.42, longitude: 114.25),
|
||||
altitude: 0,
|
||||
horizontalAccuracy: 12,
|
||||
verticalAccuracy: 10,
|
||||
timestamp: Date()
|
||||
)
|
||||
let manager = RealtimeLocationManager(driver: driver, oneShotTimeoutNanoseconds: 1_000_000_000)
|
||||
|
||||
let coordinate = await manager.requestLocation()
|
||||
|
||||
XCTAssertEqual(coordinate?.latitude ?? 0, 30.42, accuracy: 0.000001)
|
||||
XCTAssertEqual(driver.requestLocationCallCount, 0)
|
||||
XCTAssertEqual(driver.startUpdatingCallCount, 0)
|
||||
XCTAssertFalse(manager.isRequesting)
|
||||
}
|
||||
|
||||
func testContinuationIsInstalledBeforeOneShotRequest() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
|
||||
driver.onRequestLocation = {
|
||||
driver.emit(CLLocation(latitude: 22.54, longitude: 113.94))
|
||||
}
|
||||
|
||||
let coordinate = await manager.requestLocation()
|
||||
|
||||
XCTAssertEqual(coordinate?.latitude ?? 0, 22.54, accuracy: 0.000001)
|
||||
XCTAssertFalse(manager.isRequesting)
|
||||
}
|
||||
|
||||
func testUndeterminedAuthorizationWaitsBeforeRequestingLocation() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
driver.authorizationStatus = .notDetermined
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
|
||||
|
||||
let request = Task { await manager.requestLocation() }
|
||||
while !manager.isRequesting { await Task.yield() }
|
||||
|
||||
XCTAssertEqual(driver.requestAuthorizationCallCount, 1)
|
||||
XCTAssertEqual(driver.requestLocationCallCount, 0)
|
||||
|
||||
driver.emitAuthorization(.authorizedWhenInUse)
|
||||
await Task.yield()
|
||||
XCTAssertEqual(driver.requestLocationCallCount, 1)
|
||||
|
||||
driver.emit(CLLocation(latitude: 22.54, longitude: 113.94))
|
||||
let coordinate = await request.value
|
||||
XCTAssertEqual(coordinate?.latitude ?? 0, 22.54, accuracy: 0.000001)
|
||||
}
|
||||
|
||||
func testOverlappingRequestIsRejectedWithoutReplacingFirstContinuation() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
|
||||
|
||||
let first = Task { await manager.requestLocation() }
|
||||
while !manager.isRequesting { await Task.yield() }
|
||||
let second = await manager.requestLocation()
|
||||
XCTAssertNil(second)
|
||||
|
||||
driver.emit(CLLocation(latitude: 31.23, longitude: 121.47))
|
||||
let firstCoordinate = await first.value
|
||||
XCTAssertEqual(firstCoordinate?.longitude ?? 0, 121.47, accuracy: 0.000001)
|
||||
}
|
||||
|
||||
func testOneShotTimeoutTransitionsToContinuousFallback() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 5_000_000)
|
||||
|
||||
let request = Task { await manager.requestLocation() }
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(driver.startUpdatingCallCount, 1)
|
||||
|
||||
driver.emit(CLLocation(latitude: 39.90, longitude: 116.40))
|
||||
let coordinate = await request.value
|
||||
XCTAssertEqual(coordinate?.latitude ?? 0, 39.90, accuracy: 0.000001)
|
||||
XCTAssertEqual(driver.stopUpdatingCallCount, 1)
|
||||
}
|
||||
|
||||
func testInvalidAccuracyCannotCompleteRequest() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
|
||||
|
||||
let request = Task { await manager.requestLocation() }
|
||||
while !manager.isRequesting { await Task.yield() }
|
||||
driver.emit(CLLocation(
|
||||
coordinate: .init(latitude: 22.54, longitude: 113.94),
|
||||
altitude: 0,
|
||||
horizontalAccuracy: -1,
|
||||
verticalAccuracy: 10,
|
||||
timestamp: Date()
|
||||
))
|
||||
await Task.yield()
|
||||
XCTAssertTrue(manager.isRequesting)
|
||||
XCTAssertNil(manager.location)
|
||||
|
||||
driver.emit(CLLocation(latitude: 31.23, longitude: 121.47))
|
||||
let coordinate = await request.value
|
||||
XCTAssertEqual(coordinate?.longitude ?? 0, 121.47, accuracy: 0.000001)
|
||||
}
|
||||
|
||||
func testDeniedLocationErrorFinishesWithoutStartingFallback() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
|
||||
|
||||
let request = Task { await manager.requestLocation() }
|
||||
while !manager.isRequesting { await Task.yield() }
|
||||
driver.emitError(NSError(domain: kCLErrorDomain, code: CLError.denied.rawValue))
|
||||
|
||||
let coordinate = await request.value
|
||||
XCTAssertNil(coordinate)
|
||||
XCTAssertEqual(driver.startUpdatingCallCount, 0)
|
||||
XCTAssertFalse(manager.isRequesting)
|
||||
}
|
||||
|
||||
func testOldTimestampCannotCompleteNewRequest() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
|
||||
|
||||
let request = Task { await manager.requestLocation() }
|
||||
while !manager.isRequesting { await Task.yield() }
|
||||
driver.emit(CLLocation(
|
||||
coordinate: .init(latitude: 1, longitude: 2),
|
||||
altitude: 0,
|
||||
horizontalAccuracy: 10,
|
||||
verticalAccuracy: 10,
|
||||
timestamp: Date(timeIntervalSinceNow: -60)
|
||||
))
|
||||
await Task.yield()
|
||||
XCTAssertTrue(manager.isRequesting)
|
||||
|
||||
driver.emit(CLLocation(latitude: 22.54, longitude: 113.94))
|
||||
let coordinate = await request.value
|
||||
XCTAssertEqual(coordinate?.latitude ?? 0, 22.54, accuracy: 0.000001)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeRealtimeLocationDriver: RealtimeLocationDriving {
|
||||
var location: CLLocation?
|
||||
var authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse
|
||||
weak var delegate: CLLocationManagerDelegate?
|
||||
var onRequestLocation: (() -> Void)?
|
||||
private(set) var requestAuthorizationCallCount = 0
|
||||
private(set) var requestLocationCallCount = 0
|
||||
private(set) var startUpdatingCallCount = 0
|
||||
private(set) var stopUpdatingCallCount = 0
|
||||
|
||||
func requestWhenInUseAuthorization() {
|
||||
requestAuthorizationCallCount += 1
|
||||
}
|
||||
|
||||
func requestLocation() {
|
||||
requestLocationCallCount += 1
|
||||
onRequestLocation?()
|
||||
}
|
||||
|
||||
func startUpdatingLocation() {
|
||||
startUpdatingCallCount += 1
|
||||
}
|
||||
|
||||
func stopUpdatingLocation() {
|
||||
stopUpdatingCallCount += 1
|
||||
}
|
||||
|
||||
func emitAuthorization(_ status: CLAuthorizationStatus) {
|
||||
authorizationStatus = status
|
||||
delegate?.locationManagerDidChangeAuthorization?(CLLocationManager())
|
||||
}
|
||||
|
||||
func emit(_ location: CLLocation) {
|
||||
delegate?.locationManager?(CLLocationManager(), didUpdateLocations: [location])
|
||||
}
|
||||
|
||||
func emitError(_ error: Error) {
|
||||
delegate?.locationManager?(CLLocationManager(), didFailWithError: error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user