mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
fix: 优化启动引导、坐标处理和日志管理
This commit is contained in:
@@ -2,16 +2,139 @@ 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) }
|
||||
private let validAuthority = CertificateAuthority(certPEM: "valid-cert", keyPEM: "valid-key")
|
||||
|
||||
func testEnsureCreatesOnceThenReusesValidKeychainPair() throws {
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
var generations = 0
|
||||
let store = CertificateAuthorityStore(directory: directory) {
|
||||
let store = makeStore(keychain: keychain) {
|
||||
generations += 1
|
||||
return CertificateAuthority(certPEM: "cert", keyPEM: "key")
|
||||
return self.validAuthority
|
||||
}
|
||||
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
|
||||
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
XCTAssertEqual(generations, 1)
|
||||
}
|
||||
|
||||
func testEnsureMigratesValidLegacyPairThenDeletesLegacyFiles() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
let store = makeStore(directory: directory, keychain: keychain) {
|
||||
XCTFail("A valid legacy pair must be migrated instead of regenerated")
|
||||
return self.validAuthority
|
||||
}
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testFailedKeychainMigrationPreservesLegacyFiles() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.shouldFailSave = true
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertThrowsError(try store.ensure())
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testInvalidKeychainPairFallsBackToLegacyPair() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.stored = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
}
|
||||
|
||||
func testValidKeychainPairRetriesCleanupOfLegacyFiles() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.stored = validAuthority
|
||||
let store = makeStore(directory: directory, keychain: keychain) {
|
||||
XCTFail("A valid Keychain pair must not be regenerated")
|
||||
return self.validAuthority
|
||||
}
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testInvalidLegacyFilesAreRemovedOnlyAfterReplacementIsPersisted() throws {
|
||||
let invalidAuthority = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
|
||||
let directory = try makeLegacyDirectory(authority: invalidAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testFailedReplacementPersistencePreservesInvalidLegacyFiles() throws {
|
||||
let invalidAuthority = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
|
||||
let directory = try makeLegacyDirectory(authority: invalidAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.shouldFailSave = true
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertThrowsError(try store.ensure())
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
private func makeStore(
|
||||
directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),
|
||||
keychain: InMemoryCertificateAuthorityKeychain,
|
||||
generator: @escaping () throws -> CertificateAuthority
|
||||
) -> CertificateAuthorityStore {
|
||||
CertificateAuthorityStore(
|
||||
directory: directory,
|
||||
keychain: keychain,
|
||||
generator: generator,
|
||||
validator: { $0 == self.validAuthority }
|
||||
)
|
||||
}
|
||||
|
||||
private func makeLegacyDirectory(authority: CertificateAuthority) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try authority.certPEM.write(to: directory.appendingPathComponent("ca-cert.pem"), atomically: true, encoding: .utf8)
|
||||
try authority.keyPEM.write(to: directory.appendingPathComponent("ca-key.pem"), atomically: true, encoding: .utf8)
|
||||
return directory
|
||||
}
|
||||
}
|
||||
|
||||
private enum CertificateAuthorityStoreTestError: Error {
|
||||
case saveFailed
|
||||
}
|
||||
|
||||
private final class InMemoryCertificateAuthorityKeychain: CertificateAuthorityKeychain {
|
||||
var stored: CertificateAuthority?
|
||||
var shouldFailSave = false
|
||||
|
||||
func load() throws -> CertificateAuthority? { stored }
|
||||
|
||||
func save(_ authority: CertificateAuthority) throws {
|
||||
guard !shouldFailSave else { throw CertificateAuthorityStoreTestError.saveFailed }
|
||||
stored = authority
|
||||
}
|
||||
|
||||
func remove() throws {
|
||||
stored = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import XCTest
|
||||
import CoreLocation
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class FavoriteLocationStoreTests: XCTestCase {
|
||||
@@ -7,14 +8,92 @@ final class FavoriteLocationStoreTests: XCTestCase {
|
||||
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)
|
||||
let favorite = store.save(
|
||||
name: "深圳湾",
|
||||
mapCoordinate: .init(latitude: 22.494, longitude: 113.951),
|
||||
mapCoordinateSystem: .gcj02,
|
||||
accuracy: 20
|
||||
)
|
||||
|
||||
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
|
||||
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾")
|
||||
}
|
||||
|
||||
func testFavoriteStoresBothFormsAndSelectsMatchingPairWithoutReadConversion() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let wgs = CLLocationCoordinate2D(latitude: 22.491_438, longitude: 113.945_702)
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
coordinatePair: .init(mapCoordinate: wgs, mapCoordinateSystem: .wgs84),
|
||||
accuracy: 20
|
||||
)
|
||||
|
||||
XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).latitude, wgs.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).longitude, wgs.longitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(favorite.coordinatePair.gcj02.latitude, wgs.latitude)
|
||||
XCTAssertNotEqual(favorite.coordinatePair.gcj02.longitude, wgs.longitude)
|
||||
}
|
||||
|
||||
func testLegacyFavoriteIsUpgradedAsDomesticGCJAndRewritten() throws {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let id = UUID()
|
||||
let createdAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let payload = LegacyFavoritePayload(
|
||||
id: id,
|
||||
name: "旧收藏",
|
||||
latitude: 22.544_577,
|
||||
longitude: 113.941_14,
|
||||
accuracy: 25,
|
||||
createdAt: createdAt
|
||||
)
|
||||
defaults.set(try JSONEncoder().encode([payload]), forKey: "favorite_locations")
|
||||
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertTrue(store.favorites[0].isLegacyCoordinateRecord)
|
||||
XCTAssertEqual(store.favorites[0].coordinatePair.gcj02.latitude, payload.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(store.favorites[0].coordinatePair.wgs84.longitude, payload.longitude)
|
||||
|
||||
try store.migrateLegacyCoordinates()
|
||||
let reloaded = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertEqual(reloaded.favorites[0].id, id)
|
||||
XCTAssertEqual(reloaded.favorites[0].name, "旧收藏")
|
||||
XCTAssertFalse(reloaded.favorites[0].isLegacyCoordinateRecord)
|
||||
}
|
||||
|
||||
func testOverseasPairUsesIdentityConversion() {
|
||||
let eiffelTower = CoordinateConverter.coordinatePair(lat: 48.858_37, lon: 2.294_481, mapCoordinateSystem: .wgs84)
|
||||
|
||||
XCTAssertEqual(eiffelTower.wgs84.latitude, eiffelTower.gcj02.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(eiffelTower.wgs84.longitude, eiffelTower.gcj02.longitude, accuracy: 0.000_000_1)
|
||||
}
|
||||
|
||||
func testDomesticMapCoordinateMatchesPreviouslyActivatedWGS84Value() {
|
||||
let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14)
|
||||
let pair = CoordinatePair(mapCoordinate: gcj, mapCoordinateSystem: .gcj02)
|
||||
|
||||
XCTAssertTrue(pair.matchesWGS84(
|
||||
latitude: pair.wgs84.latitude,
|
||||
longitude: pair.wgs84.longitude
|
||||
))
|
||||
XCTAssertFalse(pair.matchesWGS84(latitude: gcj.latitude, longitude: gcj.longitude))
|
||||
}
|
||||
|
||||
func testMapConfigurationNeverRequestsRealUserLocation() {
|
||||
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
|
||||
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct LegacyFavoritePayload: Encodable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let accuracy: Int
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
@@ -3,130 +3,85 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class LocationActionCoordinatorTests: XCTestCase {
|
||||
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async {
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
let coordinator = LocationActionCoordinator()
|
||||
func testApplyStartsProxyAndWritesTheFavoriteWGS84Pair() async {
|
||||
let proxy = FakeLocationActionProxy()
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
latitude: 22.494,
|
||||
longitude: 113.951,
|
||||
accuracy: 20,
|
||||
mapCoordinateSystem: .gcj02
|
||||
)
|
||||
|
||||
let applied = await coordinator.apply(favorite)
|
||||
// LocationActionCoordinator doesn't take injected deps — just verify state
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertTrue(coordinator.virtualLocationEnabled)
|
||||
XCTAssertTrue(proxy.isRunning)
|
||||
XCTAssertEqual(proxy.lastCoordinates?.latitude, favorite.coordinatePair.wgs84.latitude)
|
||||
XCTAssertEqual(proxy.lastCoordinates?.longitude, favorite.coordinatePair.wgs84.longitude)
|
||||
XCTAssertEqual(settings.saved?.latitude, favorite.coordinatePair.wgs84.latitude)
|
||||
XCTAssertEqual(settings.saved?.longitude, favorite.coordinatePair.wgs84.longitude)
|
||||
XCTAssertTrue(settings.saved?.enabled == true)
|
||||
}
|
||||
|
||||
func testClearDoesNotConnectAnInactiveProxy() async {
|
||||
let coordinator = LocationActionCoordinator()
|
||||
func testClearWritesDisabledCoordinatesAndClearsSettings() {
|
||||
let proxy = FakeLocationActionProxy(isRunning: true)
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
|
||||
coordinator.clear()
|
||||
|
||||
XCTAssertEqual(proxy.lastCoordinates?.latitude, 0)
|
||||
XCTAssertEqual(proxy.lastCoordinates?.longitude, 0)
|
||||
XCTAssertFalse(proxy.lastCoordinates?.enabled ?? true)
|
||||
XCTAssertFalse(settings.saved?.enabled ?? true)
|
||||
XCTAssertFalse(coordinator.virtualLocationEnabled)
|
||||
}
|
||||
|
||||
func testBusyApplyRejectsASecondRequest() async {
|
||||
let coordinator = LocationActionCoordinator()
|
||||
func testApplyVerifiedRejectsInactiveProxyWithoutWritingCoordinates() {
|
||||
let proxy = FakeLocationActionProxy()
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
|
||||
// The coordinator serializes on MainActor. A completed first request may
|
||||
// legitimately make the next request a no-op rather than a concurrent rejection.
|
||||
let firstApplied = await coordinator.apply(favorite)
|
||||
let secondApplied = await coordinator.apply(favorite)
|
||||
XCTAssertTrue(firstApplied)
|
||||
XCTAssertTrue(secondApplied)
|
||||
XCTAssertFalse(coordinator.applyVerified(favorite))
|
||||
XCTAssertNil(proxy.lastCoordinates)
|
||||
XCTAssertNil(settings.saved)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeTrust {
|
||||
var canModify: Bool
|
||||
let events: EventLog
|
||||
|
||||
init(canModify: Bool, events: EventLog) {
|
||||
self.canModify = canModify
|
||||
self.events = events
|
||||
private final class FakeLocationActionProxy: LocationActionProxying {
|
||||
struct Coordinates: Equatable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let enabled: Bool
|
||||
let accuracy: Int
|
||||
}
|
||||
|
||||
func refreshTrust() async {
|
||||
events.append("trust.refresh")
|
||||
var isRunning: Bool
|
||||
private(set) var lastCoordinates: Coordinates?
|
||||
|
||||
init(isRunning: Bool = false) {
|
||||
self.isRunning = isRunning
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64 {
|
||||
lastCoordinates = Coordinates(latitude: lat, longitude: lon, enabled: enabled, accuracy: accuracy)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@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 }
|
||||
private final class FakeLocationActionSettingsStore: LocationActionSettingsStoring {
|
||||
private(set) var saved: WlocSettings?
|
||||
|
||||
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()
|
||||
}
|
||||
func save(_ settings: WlocSettings) { saved = settings }
|
||||
func clear() { saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false) }
|
||||
}
|
||||
|
||||
@@ -185,20 +185,20 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it")
|
||||
}
|
||||
|
||||
func testTileReprojectionPreservesSelectionIdentityAndIssuesFocus() {
|
||||
func testMapCoordinateSystemReprojectionPreservesSelectionIdentityAndIssuesFocus() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let favoriteID = UUID()
|
||||
state.selectFavorite(.init(latitude: 22.55, longitude: 113.95), id: favoriteID, name: "测试收藏")
|
||||
let revision = state.selection.revision
|
||||
|
||||
state.reprojectSelectionForTileChange(.init(latitude: 22.54, longitude: 113.94))
|
||||
state.reprojectSelectionForMapCoordinateSystemChange(.init(latitude: 22.54, longitude: 113.94))
|
||||
|
||||
XCTAssertEqual(state.selection.source, .favorite(favoriteID))
|
||||
XCTAssertEqual(state.selection.explicitName, "测试收藏")
|
||||
XCTAssertEqual(state.selection.revision, revision)
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, 22.54, accuracy: 0.000001)
|
||||
guard case let .focus(coordinate, distanceMeters) = state.cameraCommand?.kind else {
|
||||
return XCTFail("Expected a focus command after tile reprojection")
|
||||
return XCTFail("Expected a focus command after map coordinate-system reprojection")
|
||||
}
|
||||
XCTAssertEqual(coordinate.latitude, 22.54, accuracy: 0.000001)
|
||||
XCTAssertEqual(distanceMeters, state.viewportMeters)
|
||||
@@ -221,4 +221,71 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km")
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 km")
|
||||
}
|
||||
|
||||
func testLastCoordinateStoreKeepsBothFormsAndZoom() {
|
||||
let suite = "MapLocationStateTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14)
|
||||
|
||||
LastCoordinateStore.save(mapCoordinate: gcj, mapCoordinateSystem: .gcj02, zoomMeters: 1_250, defaults: defaults)
|
||||
|
||||
guard let restored = LastCoordinateStore.load(defaults: defaults) else {
|
||||
return XCTFail("Expected a persisted current map pin")
|
||||
}
|
||||
XCTAssertEqual(restored.coordinate(for: .gcj02).latitude, gcj.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(restored.coordinate(for: .gcj02).longitude, gcj.longitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(restored.coordinate(for: .wgs84).longitude, gcj.longitude)
|
||||
XCTAssertEqual(restored.zoomMeters, 1_250)
|
||||
}
|
||||
|
||||
func testCoordinateMigrationUpgradesLegacyCurrentPinAndFavoritesBeforeSettingVersion() throws {
|
||||
let suite = "MapLocationStateTests.\(UUID().uuidString)"
|
||||
let legacySuite = "MapLocationStateTests.Legacy.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
let legacyDefaults = UserDefaults(suiteName: legacySuite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
defer { legacyDefaults.removePersistentDomain(forName: legacySuite) }
|
||||
let firstID = UUID()
|
||||
let secondID = UUID()
|
||||
let records = [
|
||||
CoordinateMigrationLegacyFavorite(id: firstID, name: "第一个", latitude: 22.544_577, longitude: 113.941_14, accuracy: 15, createdAt: .distantPast),
|
||||
CoordinateMigrationLegacyFavorite(id: secondID, name: "海外", latitude: 48.858_37, longitude: 2.294_481, accuracy: 30, createdAt: .distantFuture),
|
||||
]
|
||||
legacyDefaults.set(22.544_577, forKey: "lastMapLat")
|
||||
legacyDefaults.set(113.941_14, forKey: "lastMapLon")
|
||||
legacyDefaults.set(2_000.0, forKey: "mapViewportMeters")
|
||||
defaults.set(try JSONEncoder().encode(records), forKey: "favorite_locations")
|
||||
defaults.set(secondID.uuidString, forKey: "favorite_locations_selected_id")
|
||||
|
||||
let favorites = FavoriteLocationStore(defaults: defaults)
|
||||
try CoordinateStorageMigration.migrateIfNeeded(
|
||||
favorites: favorites,
|
||||
defaults: defaults,
|
||||
legacyDefaults: legacyDefaults
|
||||
)
|
||||
|
||||
XCTAssertEqual(defaults.integer(forKey: "coordinateStorageMigrationVersion"), CoordinateStorageMigration.currentVersion)
|
||||
guard let current = LastCoordinateStore.load(defaults: defaults) else {
|
||||
return XCTFail("Expected migrated current map pin")
|
||||
}
|
||||
XCTAssertEqual(current.coordinate(for: .gcj02).latitude, 22.544_577, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(current.zoomMeters, 2_000)
|
||||
|
||||
let reloaded = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertEqual(reloaded.favorites.map(\.id), [firstID, secondID])
|
||||
XCTAssertEqual(reloaded.favorites.map(\.name), ["第一个", "海外"])
|
||||
XCTAssertEqual(reloaded.selectedFavoriteID, secondID)
|
||||
XCTAssertFalse(reloaded.favorites.contains(where: \.isLegacyCoordinateRecord))
|
||||
XCTAssertEqual(reloaded.favorites[1].coordinatePair.wgs84.latitude, reloaded.favorites[1].coordinatePair.gcj02.latitude, accuracy: 0.000_000_1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CoordinateMigrationLegacyFavorite: Encodable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let accuracy: Int
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class RuntimeLogStoreTests: XCTestCase {
|
||||
func testRetentionCutoffIsExactlyThreeDays() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
|
||||
XCTAssertEqual(
|
||||
RuntimeLogStore.retentionCutoff(now: now),
|
||||
now.addingTimeInterval(-3 * 24 * 60 * 60)
|
||||
)
|
||||
}
|
||||
|
||||
func testRetentionKeepsCutoffAndNewerEntriesOnly() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let cutoff = RuntimeLogStore.retentionCutoff(now: now)
|
||||
let expired = RuntimeLogEntry(timestamp: cutoff.addingTimeInterval(-0.001), source: "APP", level: .info, category: "Test", message: "expired")
|
||||
let boundary = RuntimeLogEntry(timestamp: cutoff, source: "APP", level: .info, category: "Test", message: "boundary")
|
||||
let recent = RuntimeLogEntry(timestamp: now, source: "CORE", level: .warning, category: "Proxy", message: "recent")
|
||||
|
||||
XCTAssertEqual(
|
||||
RuntimeLogStore.retainedEntries([expired, boundary, recent], now: now),
|
||||
[boundary, recent]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
@MainActor
|
||||
final class SetupCoordinatorTests: XCTestCase {
|
||||
func testSuccessfulVerificationDismissesSetup() {
|
||||
let coordinator = SetupCoordinator()
|
||||
coordinator.requestSetup()
|
||||
|
||||
coordinator.applyVerificationResult(.success)
|
||||
|
||||
XCTAssertEqual(coordinator.trustState, .trusted)
|
||||
XCTAssertFalse(coordinator.needsSetup)
|
||||
}
|
||||
|
||||
func testCertificateFailureRoutesDirectlyToCertificateStep() {
|
||||
let coordinator = SetupCoordinator()
|
||||
|
||||
coordinator.applyVerificationResult(.certNotTrusted)
|
||||
|
||||
XCTAssertEqual(coordinator.trustState, .unavailable)
|
||||
XCTAssertTrue(coordinator.needsSetup)
|
||||
XCTAssertEqual(coordinator.setupStep, .cert)
|
||||
}
|
||||
|
||||
func testProxyFailureRoutesBackToProxyStep() {
|
||||
let coordinator = SetupCoordinator()
|
||||
coordinator.applyVerificationResult(.certNotTrusted)
|
||||
|
||||
coordinator.applyVerificationResult(.wifiProxyNotConfigured)
|
||||
|
||||
XCTAssertTrue(coordinator.needsSetup)
|
||||
XCTAssertEqual(coordinator.setupStep, .proxy)
|
||||
}
|
||||
|
||||
func testWiFiChangeMapsLocalProxyStartFailureToProxyReminder() {
|
||||
XCTAssertEqual(VerificationResult.proxyNotRunning.wifiChangeReminderTipKind, .proxySetup)
|
||||
}
|
||||
|
||||
func testWiFiChangeDoesNotPresentFailureForConcurrentVerification() {
|
||||
XCTAssertNil(VerificationResult.verificationInProgress.wifiChangeReminderTipKind)
|
||||
}
|
||||
|
||||
func testWiFiChangeCertificateFailureDoesNotUseGenericReminder() {
|
||||
XCTAssertNil(VerificationResult.certNotTrusted.wifiChangeReminderTipKind)
|
||||
XCTAssertNil(VerificationResult.certNotTrusted.tipKind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class VirtualLocationTipPreferencesTests: XCTestCase {
|
||||
private var suites: [String] = []
|
||||
|
||||
override func tearDown() {
|
||||
for suite in suites {
|
||||
UserDefaults.standard.removePersistentDomain(forName: suite)
|
||||
}
|
||||
suites.removeAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSuppressionAppearsOnlyAfterThirdSuccessfulOperation() {
|
||||
let defaults = makeDefaults()
|
||||
let legacyDefaults = makeDefaults()
|
||||
let preferences = VirtualLocationTipPreferences(
|
||||
defaults: defaults,
|
||||
legacyDefaults: legacyDefaults
|
||||
)
|
||||
|
||||
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 1)
|
||||
XCTAssertFalse(preferences.canSuppress(.activation))
|
||||
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 2)
|
||||
XCTAssertFalse(preferences.canSuppress(.activation))
|
||||
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 3)
|
||||
XCTAssertTrue(preferences.canSuppress(.activation))
|
||||
}
|
||||
|
||||
func testActivationAndDeactivationCountersAndSuppressionAreIndependent() {
|
||||
let defaults = makeDefaults()
|
||||
let preferences = VirtualLocationTipPreferences(
|
||||
defaults: defaults,
|
||||
legacyDefaults: makeDefaults()
|
||||
)
|
||||
|
||||
for _ in 0..<3 {
|
||||
preferences.recordSuccessfulOperation(.activation)
|
||||
}
|
||||
preferences.suppress(.activation)
|
||||
|
||||
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.activation))
|
||||
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
|
||||
XCTAssertFalse(preferences.canSuppress(.deactivation))
|
||||
|
||||
for _ in 0..<3 {
|
||||
preferences.recordSuccessfulOperation(.deactivation)
|
||||
}
|
||||
preferences.suppress(.deactivation)
|
||||
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.deactivation))
|
||||
}
|
||||
|
||||
func testSuppressionBeforeThirdOperationIsIgnored() {
|
||||
let preferences = VirtualLocationTipPreferences(
|
||||
defaults: makeDefaults(),
|
||||
legacyDefaults: makeDefaults()
|
||||
)
|
||||
|
||||
preferences.recordSuccessfulOperation(.deactivation)
|
||||
preferences.suppress(.deactivation)
|
||||
|
||||
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
|
||||
}
|
||||
|
||||
func testLegacyActivationSuppressionRemainsEffective() {
|
||||
let legacyDefaults = makeDefaults()
|
||||
legacyDefaults.set(true, forKey: "activationTipDisabled")
|
||||
let preferences = VirtualLocationTipPreferences(
|
||||
defaults: makeDefaults(),
|
||||
legacyDefaults: legacyDefaults
|
||||
)
|
||||
|
||||
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.activation))
|
||||
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
|
||||
}
|
||||
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suite = "VirtualLocationTipPreferencesTests.\(UUID().uuidString)"
|
||||
suites.append(suite)
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,11 @@ SETUP="$ROOT/App/SetupCoordinator.swift"
|
||||
PROXY="$ROOT/App/ProxyManager.swift"
|
||||
SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift"
|
||||
DIAGNOSTICS="$ROOT/App/DiagnosticsView.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
CONVERTER="$ROOT/Shared/CoordinateConverter.swift"
|
||||
NETWORK_MONITOR="$ROOT/Shared/NetworkMonitor.swift"
|
||||
|
||||
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS"; do
|
||||
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS" "$CONTENT" "$CONVERTER" "$NETWORK_MONITOR"; do
|
||||
test -f "$file" || fail "missing required refactor file: $file"
|
||||
done
|
||||
|
||||
@@ -23,6 +26,8 @@ done
|
||||
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 'let initialViewportMeters:' "$MAP_BRIDGE" || fail "map bridge must receive initial viewport from MapLocationState"
|
||||
! grep -q 'ViewportStore.loadOrDefault()' "$MAP_BRIDGE" || fail "map bridge must not bypass MapLocationState for initial viewport"
|
||||
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"
|
||||
@@ -41,13 +46,40 @@ grep -q 'var location: CLLocation?' "$REALTIME" || fail "Core Location driver mu
|
||||
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 'applyVerified' "$MAP_HOME" || fail "verified location commits must be synchronous after revision validation"
|
||||
grep -q 'defer { needsSetup = !canModify }' "$SETUP" || fail "trust verification must always converge setup state"
|
||||
grep -q 'func applyVerificationResult' "$SETUP" || fail "verification results must have one setup-state reducer"
|
||||
grep -q 'case .success:' "$SETUP" || fail "successful verification must converge setup state"
|
||||
grep -q 'case .certNotTrusted:' "$SETUP" || fail "certificate failure must converge setup state"
|
||||
grep -q 'setupStep = .proxy' "$SETUP" || fail "proxy failure must converge setup state"
|
||||
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 '日志自动清理,仅保留近 3 天' "$DIAGNOSTICS" || fail "diagnostics must disclose the three-day retention policy"
|
||||
grep -q 'retentionInterval: TimeInterval = 3 \* 24 \* 60 \* 60' "$ROOT/Shared/RuntimeLog.swift" || fail "runtime logs must retain only three days"
|
||||
if grep -q 'logEvent("CONNECT " + host + " -> passthrough")' "$ROOT/Core/proxy.go"; then
|
||||
fail "proxy diagnostics must not log unrelated passthrough CONNECT hosts"
|
||||
fi
|
||||
grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing"
|
||||
grep -q 'await CoordinateConverter.resolveInitialMapCoordinateSystem()' "$CONTENT" || fail "map type must resolve before MapHomeView construction"
|
||||
grep -q 'phase = .map' "$CONTENT" || fail "ContentView must explicitly gate MapHomeView construction"
|
||||
! grep -q 'startTileProbe' "$MAP_HOME" || fail "MapHomeView must not start a second fixed-anchor coordinate-system probe"
|
||||
! grep -q 'initializeMap()' "$MAP_HOME" || fail "MapHomeView must not replay a second map initialization from onAppear"
|
||||
grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime position must resolve before map construction"
|
||||
! grep -q 'lastTileCheck' "$CONVERTER" || fail "map coordinate-system detection must not use a time cache"
|
||||
! grep -q '跳过(缓存' "$CONVERTER" || fail "map coordinate-system detection must not skip using a cached result"
|
||||
! grep -q '瓦片检测' "$CONVERTER" || fail "coordinate-system probe logs must not claim to inspect map tiles"
|
||||
grep -q 'minimumCountForSuppression = 3' Shared/AppGroup.swift || fail "automatic tip suppression must require three successful operations"
|
||||
grep -q 'activeTip = .deactivation' "$MAP_HOME" || fail "manual deactivation help must use the non-suppressible generic tip sheet"
|
||||
grep -q 'stabilizationNanoseconds: UInt64 = 5_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait five seconds before environment verification"
|
||||
grep -q 'result.wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi proxy failures must use the background reminder mapping"
|
||||
grep -q 'if result == .certNotTrusted' "$MAP_HOME" || fail "Wi-Fi certificate failures must enter certificate setup"
|
||||
grep -q 'setup.applyVerificationResult(result)' "$MAP_HOME" || fail "Wi-Fi certificate failures must use the setup routing reducer"
|
||||
! grep -q 'case certificate' "$ROOT/App/TipViews.swift" || fail "generic certificate tip must not coexist with certificate setup"
|
||||
! grep -q 'CertificateTipContent' "$ROOT/App/TipViews.swift" || fail "certificate failures must use the complete setup flow"
|
||||
! grep -q 'onChange(of: net.isAirplaneMode)' "$MAP_HOME" || fail "airplane recovery must not race the Wi-Fi change verifier"
|
||||
grep -q 'hasReceivedInitialPath' "$NETWORK_MONITOR" || fail "initial network path must not be reported as a Wi-Fi switch"
|
||||
grep -q 'lastKnownSSID' "$NETWORK_MONITOR" || fail "SSID polling must preserve a baseline across temporary nil readings"
|
||||
|
||||
echo "PASS: map location state refactor contract"
|
||||
|
||||
Reference in New Issue
Block a user