Files
bitchat/bitchatTests/Services/LocationStateManagerTests.swift
T
733098bb63 fix: use country-level resolution for low-precision geohashes (#1044)
* fix: use country-level resolution for low-precision geohashes (#887)

* fix: skip admin fallback when country exists but is duplicate

Prevents mixed labels like "United Kingdom and Scotland" for
single-country geohashes. Admin fallback now only triggers when
no country is available from the placemark.

* fix: migrate stale low-precision bookmark names so country-first logic applies

Users who bookmarked a <=2-char geohash before the country-first resolver
kept the old administrativeArea cache (e.g. "England" for `gc`) because
resolveBookmarkNameIfNeeded bails when bookmarkNames[gh] is non-nil. Add a
one-shot, versioned migration that drops cached names for <=2-char geohashes
on load; the next LocationChannelsSheet .onAppear re-resolves them via the
fixed logic. Higher-precision entries are untouched.

* Fix low-precision geohash country names

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-07-10 15:16:02 +02:00

430 lines
18 KiB
Swift

import CoreLocation
import MapKit
import XCTest
@testable import bitchat
@MainActor
final class LocationStateManagerTests: XCTestCase {
func test_loadPersistedState_normalizesBookmarksAndRestoresTeleportedSelection() async throws {
let storage = makeStorage()
let selected = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru"))
storage.set(try JSONEncoder().encode(selected), forKey: "locationChannel.selected")
storage.set(try JSONEncoder().encode(["u4pru"]), forKey: "locationChannel.teleportedSet")
storage.set(try JSONEncoder().encode(["#U4PRU", "u4pru", ""]), forKey: "locationChannel.bookmarks")
let manager = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let deniedLoaded = await waitUntil { manager.permissionState == .denied }
XCTAssertTrue(deniedLoaded)
XCTAssertEqual(manager.bookmarks, ["u4pru"])
XCTAssertEqual(manager.selectedChannel, selected)
let teleportedLoaded = await waitUntil { manager.teleported }
XCTAssertTrue(teleportedLoaded)
}
func test_enableLocationChannels_requestsAuthorizationWhenStatusIsUndetermined() {
let locationManager = MockLocationManager(authorizationStatus: .notDetermined)
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: locationManager,
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
manager.enableLocationChannels()
XCTAssertEqual(locationManager.requestAuthorizationCallCount, 1)
XCTAssertEqual(locationManager.requestLocationCallCount, 0)
}
func test_enableLocationChannels_requestsOneShotLocationWhenAuthorized() async {
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: locationManager,
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }
XCTAssertTrue(authorizedLoaded)
manager.enableLocationChannels()
XCTAssertEqual(locationManager.requestLocationCallCount, 1)
XCTAssertEqual(manager.permissionState, .authorized)
}
func test_beginAndEndLiveRefresh_adjustLocationManagerMode() async {
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: locationManager,
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }
XCTAssertTrue(authorizedLoaded)
manager.beginLiveRefresh()
XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1)
XCTAssertEqual(locationManager.requestLocationCallCount, 1)
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyNearestTenMeters)
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterLiveMeters)
manager.endLiveRefresh()
XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1)
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
}
func test_permissionRevocation_endsLiveRefreshWithoutDiscardingExplicitState() async {
let storage = makeStorage()
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
let manager = LocationStateManager(
storage: storage,
locationManager: locationManager,
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let authorized = await waitUntil { manager.permissionState == .authorized }
XCTAssertTrue(authorized)
manager.locationManager(
CLLocationManager(),
didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)]
)
let channelsLoaded = await waitUntil { !manager.availableChannels.isEmpty }
XCTAssertTrue(channelsLoaded)
let cachedChannels = manager.availableChannels
manager.addBookmark("u4pru")
manager.markTeleported(for: "9q8yy", true)
manager.select(.location(GeohashChannel(level: .city, geohash: "9q8yy")))
let teleported = await waitUntil { manager.teleported }
XCTAssertTrue(teleported)
manager.beginLiveRefresh()
XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1)
manager.locationManager(CLLocationManager(), didChangeAuthorization: .restricted)
let restricted = await waitUntil { manager.permissionState == .restricted }
XCTAssertTrue(restricted)
XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1)
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
XCTAssertEqual(manager.availableChannels, cachedChannels, "cached display state can remain")
XCTAssertEqual(manager.bookmarks, ["u4pru"])
XCTAssertTrue(manager.teleported, "an explicit remote selection survives device revocation")
}
func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(
placemarks: [
makePlacemark(
country: "United States",
administrativeArea: "Hawaii",
locality: "Honolulu",
subLocality: "Waikiki",
name: "Hilton Hawaiian Village"
)
]
)
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),
geocoder: geocoder,
shouldInitializeCoreLocation: true
)
let location = CLLocation(latitude: 21.2850, longitude: -157.8357)
manager.locationManager(CLLocationManager(), didUpdateLocations: [location])
let channelsAndNamesLoaded = await waitUntil {
manager.availableChannels.count == GeohashChannelLevel.allCases.count &&
manager.locationNames[.city] == "Honolulu" &&
manager.locationNames[.building] == "Hilton Hawaiian Village"
}
XCTAssertTrue(channelsAndNamesLoaded)
XCTAssertEqual(geocoder.cancelCallCount, 1)
XCTAssertEqual(geocoder.reverseRequests.count, 1)
XCTAssertEqual(manager.availableChannels.map(\.geohash.count), GeohashChannelLevel.allCases.map(\.precision))
XCTAssertEqual(manager.locationNames[.region], "United States")
XCTAssertEqual(manager.locationNames[.province], "Hawaii")
XCTAssertEqual(manager.locationNames[.city], "Honolulu")
XCTAssertEqual(manager.locationNames[.neighborhood], "Waikiki")
XCTAssertEqual(manager.locationNames[.block], "Waikiki")
XCTAssertEqual(manager.locationNames[.building], "Hilton Hawaiian Village")
}
func test_selectingInRegionChannel_clearsTeleportedPersistence() async {
let storage = makeStorage()
let manager = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
let cityGeohash = Geohash.encode(
latitude: coordinate.latitude,
longitude: coordinate.longitude,
precision: GeohashChannelLevel.city.precision
)
let channel = GeohashChannel(level: .city, geohash: cityGeohash)
manager.locationManager(CLLocationManager(), didUpdateLocations: [CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)])
let channelAvailable = await waitUntil { manager.availableChannels.contains(channel) }
XCTAssertTrue(channelAvailable)
manager.markTeleported(for: cityGeohash, true)
manager.select(.location(channel))
let selectionSettled = await waitUntil {
manager.selectedChannel == .location(channel) && !manager.teleported
}
XCTAssertTrue(selectionSettled)
let reloaded = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let reloadedDenied = await waitUntil { reloaded.permissionState == .denied }
XCTAssertTrue(reloadedDenied)
XCTAssertEqual(reloaded.selectedChannel, .location(channel))
XCTAssertFalse(reloaded.teleported)
}
func test_loadPersistedState_migratesLowPrecisionBookmarkNamesOnce() throws {
let storage = makeStorage()
let staleNames = [
"gc": "England",
"u3": "Île-de-France",
"u4pr": "Paris",
"u4pruy": "Le Marais"
]
storage.set(try JSONEncoder().encode(staleNames), forKey: "locationChannel.bookmarkNames")
let migrated = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: false
)
XCTAssertNil(migrated.bookmarkNames["gc"])
XCTAssertNil(migrated.bookmarkNames["u3"])
XCTAssertEqual(migrated.bookmarkNames["u4pr"], "Paris")
XCTAssertEqual(migrated.bookmarkNames["u4pruy"], "Le Marais")
XCTAssertEqual(storage.integer(forKey: "locationChannel.bookmarkNamesSchemaVersion"), 1)
let persistedData = try XCTUnwrap(storage.data(forKey: "locationChannel.bookmarkNames"))
let persistedNames = try JSONDecoder().decode([String: String].self, from: persistedData)
XCTAssertEqual(persistedNames, [
"u4pr": "Paris",
"u4pruy": "Le Marais"
])
let correctedNames = [
"gc": "United Kingdom",
"u4pr": "Paris"
]
storage.set(try JSONEncoder().encode(correctedNames), forKey: "locationChannel.bookmarkNames")
let reloaded = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: false
)
XCTAssertEqual(reloaded.bookmarkNames["gc"], "United Kingdom")
XCTAssertEqual(reloaded.bookmarkNames["u4pr"], "Paris")
}
func test_addBookmark_lowPrecisionPrefersCountryOverAdministrativeAreas() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Arizona")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: geocoder,
shouldInitializeCoreLocation: false
)
manager.addBookmark("9q")
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "United States" }
XCTAssertTrue(bookmarkResolved)
XCTAssertEqual(geocoder.reverseRequests.count, 5)
XCTAssertEqual(manager.bookmarks, ["9q"])
}
func test_addBookmark_lowPrecisionFallsBackToDistinctAdministrativeAreas() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Nevada")])
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Arizona")])
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Nevada")])
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: geocoder,
shouldInitializeCoreLocation: false
)
manager.addBookmark("9q")
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" }
XCTAssertTrue(bookmarkResolved)
XCTAssertEqual(geocoder.reverseRequests.count, 5)
}
func test_addBookmark_higherPrecisionStillPrefersAdministrativeArea() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: geocoder,
shouldInitializeCoreLocation: false
)
manager.addBookmark("9q8")
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q8"] == "California" }
XCTAssertTrue(bookmarkResolved)
XCTAssertEqual(geocoder.reverseRequests.count, 1)
}
private func makeStorage() -> UserDefaults {
let suiteName = "LocationStateManagerTests-\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName)!
storage.removePersistentDomain(forName: suiteName)
addTeardownBlock {
storage.removePersistentDomain(forName: suiteName)
}
return storage
}
private func makePlacemark(
country: String? = nil,
administrativeArea: String? = nil,
locality: String? = nil,
subLocality: String? = nil,
name: String? = nil
) -> CLPlacemark {
var address: [String: Any] = [:]
if let country {
address["Country"] = country
}
if let administrativeArea {
address["State"] = administrativeArea
}
if let locality {
address["City"] = locality
}
if let subLocality {
address["SubLocality"] = subLocality
}
if let name {
address["Name"] = name
}
let placemark = MKPlacemark(
coordinate: CLLocationCoordinate2D(latitude: 21.2850, longitude: -157.8357),
addressDictionary: address
)
return CLPlacemark(placemark: placemark)
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
private final class MockLocationManager: LocationStateManaging {
weak var delegate: CLLocationManagerDelegate?
var desiredAccuracy: CLLocationAccuracy = 0
var distanceFilter: CLLocationDistance = 0
var authorizationStatus: CLAuthorizationStatus
private(set) var requestAuthorizationCallCount = 0
private(set) var requestLocationCallCount = 0
private(set) var startUpdatingLocationCallCount = 0
private(set) var stopUpdatingLocationCallCount = 0
init(authorizationStatus: CLAuthorizationStatus) {
self.authorizationStatus = authorizationStatus
}
func requestWhenInUseAuthorization() {
requestAuthorizationCallCount += 1
}
func requestLocation() {
requestLocationCallCount += 1
}
func startUpdatingLocation() {
startUpdatingLocationCallCount += 1
}
func stopUpdatingLocation() {
stopUpdatingLocationCallCount += 1
}
}
private final class MockLocationGeocoder: LocationStateGeocoding {
private struct Response {
let placemarks: [CLPlacemark]?
let error: Error?
}
private(set) var cancelCallCount = 0
private(set) var reverseRequests: [CLLocation] = []
private var responses: [Response] = []
func enqueue(placemarks: [CLPlacemark]?, error: Error? = nil) {
responses.append(Response(placemarks: placemarks, error: error))
}
func cancelGeocode() {
cancelCallCount += 1
}
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
) {
reverseRequests.append(location)
let response = responses.isEmpty ? Response(placemarks: nil, error: nil) : responses.removeFirst()
completionHandler(response.placemarks, response.error)
}
}