mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +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)
|
||||
}
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUILD_SCRIPT="$ROOT/build.sh"
|
||||
|
||||
test -x "$BUILD_SCRIPT" || fail "build.sh must be executable"
|
||||
grep -qF "build-unsigned-ipa.sh" "$BUILD_SCRIPT" || fail "build.sh must call build-unsigned-ipa.sh"
|
||||
|
||||
test -f "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must exist"
|
||||
|
||||
# Should NOT contain Tunnel references
|
||||
! grep -qF "Tunnel" "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must not reference Tunnel"
|
||||
! grep -qF "appex" "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must not embed extensions"
|
||||
|
||||
echo "PASS: root build script contract"
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
test -f "$ROOT/App/ProxyManager.swift" || fail "ProxyManager must exist"
|
||||
test -f "$ROOT/Shared/RuntimeLog.swift" || fail "RuntimeLog must exist"
|
||||
test -f "$ROOT/App/RealtimeLocationManager.swift" || fail "RealtimeLocationManager must exist"
|
||||
|
||||
# VPNManager and Tunnel must NOT exist
|
||||
test ! -f "$ROOT/App/VPNManager.swift" || fail "VPNManager must be removed"
|
||||
test ! -d "$ROOT/Tunnel" || fail "Tunnel directory must be removed"
|
||||
|
||||
# MobileConfigGenerator removed (unusable)
|
||||
test ! -f "$ROOT/Shared/MobileConfigGenerator.swift" || fail "MobileConfigGenerator must be removed"
|
||||
|
||||
# No duplicate flow test logic
|
||||
grep -q 'func runVerificationTest' "$ROOT/App/SetupCoordinator.swift" || fail "runVerificationTest must exist in SetupCoordinator"
|
||||
if grep -q 'func runFullFlowTest' "$ROOT/App/LocationActionCoordinator.swift"; then
|
||||
fail "runFullFlowTest duplicate logic must be removed"
|
||||
fi
|
||||
|
||||
echo "PASS: iOS compilation contract"
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
MAP_HOME="$ROOT/App/MapHomeView.swift"
|
||||
MAP_STATE="$ROOT/App/MapLocationState.swift"
|
||||
MAP_BRIDGE="$ROOT/App/MapViewRepresentable.swift"
|
||||
REALTIME="$ROOT/App/RealtimeLocationManager.swift"
|
||||
SETUP="$ROOT/App/SetupCoordinator.swift"
|
||||
PROXY="$ROOT/App/ProxyManager.swift"
|
||||
SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift"
|
||||
DIAGNOSTICS="$ROOT/App/DiagnosticsView.swift"
|
||||
|
||||
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS"; do
|
||||
test -f "$file" || fail "missing required refactor file: $file"
|
||||
done
|
||||
|
||||
! grep -q 'draftCoordinate' "$MAP_HOME" || fail "MapHomeView must not keep the old draftCoordinate authority"
|
||||
! grep -q 'needsZoom' "$MAP_HOME" || fail "MapHomeView must use camera commands instead of needsZoom"
|
||||
! grep -q '@Binding var coordinate' "$MAP_BRIDGE" || fail "map bridge must not write a coordinate Binding"
|
||||
grep -q 'showsUserLocation = true' "$MAP_BRIDGE" || fail "MapKit native user location must be visible"
|
||||
grep -q 'didUpdate userLocation' "$MAP_BRIDGE" || fail "MapKit native user location must feed realtime state"
|
||||
grep -q 'MapCameraCommand' "$MAP_BRIDGE" || fail "map bridge must consume MapCameraCommand"
|
||||
grep -q 'activeCameraCommandID' "$MAP_BRIDGE" || fail "programmatic map callbacks must be associated with the active camera command"
|
||||
grep -q 'UIPanGestureRecognizer' "$MAP_BRIDGE" || fail "map panning must be recognized explicitly"
|
||||
grep -q 'UIPinchGestureRecognizer' "$MAP_BRIDGE" || fail "pinch zoom must not be treated as a selected-center pan"
|
||||
! grep -q 'RealtimeLocationAnnotation' "$MAP_BRIDGE" || fail "custom realtime point must be removed in favor of MKUserLocation"
|
||||
grep -q 'selectionRevision' "$MAP_STATE" || fail "map state must reject stale async results by revision"
|
||||
grep -q 'isApproximatelyEqual(to: coordinate)' "$MAP_STATE" || fail "pure viewport changes must not replace an unchanged selection"
|
||||
grep -q 'displayName(viewportMeters:' "$MAP_STATE" || fail "place labels must depend on viewport size"
|
||||
grep -q 'var road:' "$MAP_STATE" || fail "place labels must keep road granularity separate from doorplate details"
|
||||
grep -q 'district: placemark.subLocality' "$MAP_HOME" || fail "Chinese-style sub-locality must feed the district zoom level"
|
||||
grep -Eq '@Published private\(set\) var location' "$REALTIME" || fail "realtime location must be read-only outside its manager"
|
||||
grep -q 'CLLocationCoordinate2DIsValid' "$REALTIME" || fail "realtime manager must reject invalid coordinates"
|
||||
grep -q 'horizontalAccuracy >= 0' "$REALTIME" || fail "realtime manager must reject invalid accuracy samples"
|
||||
grep -q 'kCLErrorDomain' "$REALTIME" || fail "denied Core Location errors must be terminal"
|
||||
grep -q 'case awaitingAuthorization' "$REALTIME" || fail "location requests must wait for authorization before requesting a sample"
|
||||
grep -q 'var location: CLLocation?' "$REALTIME" || fail "Core Location driver must expose its cached native sample"
|
||||
grep -q 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent"
|
||||
! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed"
|
||||
grep -q 'defer' "$SETUP" || fail "verification must restore temporary state with defer"
|
||||
grep -q 'restoreCoords' "$SETUP" || fail "verification must use revision-aware coordinate restoration"
|
||||
grep -q 'coordinateRevision' "$PROXY" || fail "proxy coordinate writes must be revisioned"
|
||||
grep -q 'setCoordsIfUnchanged' "$SETUP" || fail "verification must not overwrite a newer coordinate before its test write"
|
||||
grep -q 'applyVerified' "$MAP_HOME" || fail "verified location commits must be synchronous after revision validation"
|
||||
grep -q 'realtimeRequestTask' "$MAP_HOME" || fail "realtime button requests must be synchronously serialized"
|
||||
grep -q 'RealtimeLocationRequestContext' "$MAP_HOME" || fail "a realtime button tap must retarget an in-flight startup request instead of being ignored"
|
||||
grep -q 'CLError.network' "$MAP_HOME" || fail "reverse geocoding network failures must use bounded retry"
|
||||
grep -q 'SystemSettingsNavigator' "$MAP_HOME" || fail "settings actions must use the shared navigator"
|
||||
grep -q '复制全部日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone copy button"
|
||||
grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear button"
|
||||
grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing"
|
||||
grep -q 'MARKETING_VERSION: "0.0.4"' "$ROOT/project.yml" || fail "marketing version must be 0.0.4"
|
||||
grep -q '## \[0.0.4\] — 待发布' "$ROOT/docs/CHANGELOG.md" || fail "0.0.4 pending changelog section is missing"
|
||||
|
||||
echo "PASS: map location state refactor contract"
|
||||
Reference in New Issue
Block a user