Files
traccar-client-macos/TraccarClientTests/LocationModelTests.swift
T
jayanezj 3f351296b8 feat: add native local location capture (#4)
Add explicit permission handling, independent continuous and single capture,
location status and deterministic CI coverage.

Final decisions and validation:
https://github.com/jayanezj/traccar-client-macos/issues/4#issuecomment-6399189792
2026-10-25 15:58:05 +01:00

327 lines
15 KiB
Swift

import CoreLocation
import Foundation
import Testing
@Suite @MainActor
struct LocationModelTests {
@Test func passiveStatusChangesNeverRequestPermissionOrCapture() {
let provider = FakeLocationProvider(permission: .notDetermined)
let model = LocationModel(provider: provider)
model.refreshAvailability()
provider.publish(permission: .authorized)
#expect(!model.continuousRequested && model.singleStatus == .idle)
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
}
@Test func explicitStartRequestsPermissionOnceAndWaitsForGrant() {
let provider = FakeLocationProvider(permission: .notDetermined)
let model = LocationModel(provider: provider)
model.startContinuous()
model.startContinuous()
provider.publish(permission: .notDetermined)
#expect(provider.authorizationRequests == 1 && provider.starts.isEmpty)
provider.publish(permission: .authorized)
provider.publish(permission: .authorized)
#expect(model.continuousRequested && provider.starts.count == 1)
#expect(provider.startedAccuracies == [.medium])
}
@Test(arguments: [
LocationAvailability(permission: .denied, services: .enabled),
LocationAvailability(permission: .restricted, services: .enabled),
LocationAvailability(permission: .notDetermined, services: .disabled),
])
func blockedStatesDoNotPromptOrStart(availability: LocationAvailability) {
let provider = FakeLocationProvider()
provider.availability = availability
let model = LocationModel(provider: provider)
model.startContinuous()
model.captureOnce()
#expect(model.continuousRequested && model.singleStatus == .failed)
#expect(model.errorMessage != nil)
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
}
@Test func pauseBeforeAuthorizationPreventsDelayedStart() {
let provider = FakeLocationProvider(permission: .notDetermined)
let model = LocationModel(provider: provider)
model.startContinuous()
model.pause()
provider.publish(permission: .authorized)
#expect(!model.continuousRequested && provider.starts.isEmpty)
}
@Test func undeterminedPermissionCanBeRetriedOnlyByANewExplicitAttempt() {
let provider = FakeLocationProvider(permission: .notDetermined)
let model = LocationModel(provider: provider)
model.captureOnce()
model.cancelSingle()
model.refreshAvailability()
#expect(provider.authorizationRequests == 1)
model.captureOnce()
#expect(provider.authorizationRequests == 2)
provider.publish(permission: .denied)
model.captureOnce()
#expect(provider.authorizationRequests == 2)
}
@Test func pauseRejectsLateFixAndResumeUsesNewSession() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.startContinuous(accuracy: .high)
let old = try #require(provider.starts.last)
let stops = provider.continuousStops
model.pause()
#expect(provider.continuousStops == stops + 1)
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
#expect(model.lastFix == nil)
model.startContinuous(accuracy: .low)
let current = try #require(provider.starts.last)
#expect(old != current && provider.startedAccuracies == [.high, .low])
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
#expect(model.lastFix == nil)
provider.emit(.locations(.continuous, request: current, fixes: [try sampleFix()]))
#expect(model.lastFix != nil)
}
@Test func revokedPermissionStopsAcquisitionWithoutChangingUserIntent() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.startContinuous()
let old = try #require(provider.starts.last)
let stops = provider.continuousStops
provider.publish(permission: .denied)
#expect(provider.continuousStops > stops)
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
#expect(model.lastFix == nil && model.continuousRequested)
#expect(model.status == "Location permission denied")
provider.publish(permission: .authorized)
#expect(provider.starts.count == 2 && provider.starts.last != old)
model.pause()
provider.publish(permission: .authorized)
#expect(provider.starts.count == 2)
}
@Test func singleCaptureRemainsPausedAndDuplicatePressIsIgnored() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.captureOnce()
model.captureOnce()
let request = try #require(provider.singles.last)
#expect(provider.singles.count == 1 && provider.starts.isEmpty)
let fix = try sampleFix(age: 86_400, accuracy: 5_000)
provider.emit(.locations(.single, request: request, fixes: [fix]))
#expect(!model.continuousRequested && model.singleStatus == .received)
#expect(model.lastFix == fix && model.lastFixOrigin == .single)
#expect(model.status == "Paused")
}
@Test func singleCaptureSurvivesPausingContinuousCapture() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.startContinuous()
model.captureOnce()
let request = try #require(provider.singles.last)
let cancels = provider.singleCancellations
model.pause()
#expect(provider.singleCancellations == cancels && model.singleStatus == .waiting)
provider.emit(.locations(.single, request: request, fixes: [try sampleFix()]))
#expect(model.singleStatus == .received && !model.continuousRequested)
}
@Test func cancelSingleRejectsItsCallbacksWithoutStoppingContinuousCapture() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.startContinuous()
model.captureOnce()
let old = try #require(provider.singles.last)
model.cancelSingle()
provider.emit(.locations(.single, request: old, fixes: [try sampleFix()]))
#expect(model.lastFix == nil && model.continuousRequested && model.singleStatus == .cancelled)
model.captureOnce()
#expect(provider.singles.last != old)
provider.emit(.failure(.single, request: old, reason: .unavailable, fallback: nil))
#expect(model.singleStatus == .waiting && model.errorMessage == nil)
}
@Test func unavailableSingleUsesKnownFixWithoutInventingTimestamp() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.captureOnce()
let request = try #require(provider.singles.last)
let old = try sampleFix(age: 40 * 86_400, accuracy: 7_500)
provider.emit(.failure(.single, request: request, reason: .unavailable, fallback: old))
#expect(model.singleStatus == .lastKnown && model.lastFixOrigin == .lastKnown)
#expect(model.lastFix == old && model.errorMessage != nil)
#expect(!model.continuousRequested)
}
@Test func unavailableWithoutKnownFixDoesNotReportSuccess() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.captureOnce()
let request = try #require(provider.singles.last)
provider.emit(.failure(.single, request: request, reason: .unavailable, fallback: nil))
#expect(model.singleStatus == .failed && model.lastFix == nil && model.errorMessage != nil)
}
@Test func deniedSingleDoesNotUseFallback() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.captureOnce()
let request = try #require(provider.singles.last)
provider.emit(.failure(.single, request: request, reason: .permissionDenied, fallback: try sampleFix()))
#expect(model.singleStatus == .failed && model.lastFix == nil)
}
@Test func transientFailureKeepsContinuousIntentAndNextFixClearsError() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.startContinuous()
let request = try #require(provider.starts.last)
provider.emit(.failure(.continuous, request: request, reason: .unavailable, fallback: nil))
#expect(model.continuousRequested && model.continuousError != nil)
provider.emit(.locations(.continuous, request: request, fixes: [try sampleFix()]))
#expect(model.continuousError == nil && model.lastFix != nil)
}
@Test func newestSuppliedTimestampWinsWithinBatch() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.startContinuous()
let request = try #require(provider.starts.last)
let newer = try sampleFix(age: 10)
provider.emit(.locations(.continuous, request: request, fixes: [newer, try sampleFix(age: 60)]))
#expect(model.lastFix == newer)
}
@Test func shutdownCancelsBothOperationsAndLateUpdatesAreIgnored() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.startContinuous()
model.captureOnce()
let single = try #require(provider.singles.last)
let continuous = try #require(provider.starts.last)
model.shutdown()
provider.emit(.locations(.single, request: single, fixes: [try sampleFix()]))
provider.emit(.locations(.continuous, request: continuous, fixes: [try sampleFix()]))
#expect(!model.continuousRequested && model.singleStatus == .cancelled && model.lastFix == nil)
}
}
extension LocationModelTests {
@Test func changingReceiverPausesCaptureBeforeSaving() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
fixture.connection.value = ReceiverConnection(deviceIdentifier: "000123-test")
let provider = FakeLocationProvider()
let location = LocationModel(provider: provider)
let configuration = ConfigurationModel(storage: fixture.storage, beforeConnectionChange: { _, _ in
location.shutdown()
})
configuration.loadIfNeeded()
location.startContinuous()
location.captureOnce()
configuration.draft.connection.serverURL = "https://receiver.example"
#expect(configuration.save() && !location.continuousRequested && location.singleStatus == .cancelled)
}
@Test func savedAccuracyLoadsWithoutAccessingKeychainOrOpeningSettings() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
var configuration = AppConfiguration()
configuration.connection.deviceIdentifier = "000123-test"
configuration.tracking.accuracy = .high
try fixture.storage.save(configuration)
fixture.connection.readError = .keychain(-128)
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider, readAccuracy: {
try fixture.storage.loadTrackingPreferences().accuracy
})
model.startContinuous()
#expect(provider.startedAccuracies == [.high] && fixture.connection.reads == 0)
}
@Test func unreadablePreferencesDoNotStartCaptureOrAskPermission() {
let provider = FakeLocationProvider(permission: .notDetermined)
let model = LocationModel(provider: provider, readAccuracy: { throw ConfigurationError.unreadableStorage })
model.startContinuous()
model.captureOnce()
#expect(model.errorMessage != nil && !model.continuousRequested)
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
}
@Test func disabledServicesStopBothRequestsAndRecoveryRespectsContinuousIntent() throws {
let provider = FakeLocationProvider()
let model = LocationModel(provider: provider)
model.startContinuous()
model.captureOnce()
let old = try #require(provider.starts.last)
let stops = provider.continuousStops
provider.availability.services = .disabled
model.refreshAvailability()
#expect(provider.continuousStops > stops && provider.singleCancellations > 0)
#expect(model.continuousRequested && model.singleStatus == .failed)
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
#expect(model.lastFix == nil)
provider.availability.services = .enabled
model.refreshAvailability()
#expect(provider.starts.count == 2 && provider.starts.last != old)
#expect(provider.singles.count == 1)
}
@Test func initialAvailabilityCheckDelaysPermissionUntilServicesAreEnabled() {
let provider = FakeLocationProvider(permission: .notDetermined)
provider.availability.services = .checking
let model = LocationModel(provider: provider)
model.startContinuous()
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty)
provider.availability.services = .enabled
model.refreshAvailability()
#expect(provider.authorizationRequests == 1 && provider.starts.isEmpty)
provider.publish(permission: .authorized)
#expect(provider.starts.count == 1)
}
}
@MainActor
private final class FakeLocationProvider: LocationProviding {
var availability: LocationAvailability
var onEvent: ((LocationEvent) -> Void)?
var authorizationRequests = 0
var starts: [Int] = []
var singles: [Int] = []
var startedAccuracies: [LocationAccuracy] = []
var singleCancellations = 0
var continuousStops = 0
init(permission: LocationPermission = .authorized) {
availability = LocationAvailability(permission: permission, services: .enabled)
}
func publish(permission: LocationPermission) {
availability.permission = permission
onEvent?(.availability(availability))
}
func emit(_ event: LocationEvent) { onEvent?(event) }
func refreshAvailability() { onEvent?(.availability(availability)) }
func requestAuthorization() { authorizationRequests += 1 }
func stopContinuous() { continuousStops += 1 }
func cancelSingle() { singleCancellations += 1 }
func startContinuous(accuracy: LocationAccuracy, request: Int) {
starts.append(request)
startedAccuracies.append(accuracy)
}
func requestSingle(accuracy: LocationAccuracy, request: Int) { singles.append(request) }
}
private func sampleFix(age: TimeInterval = 0, accuracy: Double = 100) throws -> LocationFix {
try #require(LocationFix(CLLocation(
coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2), altitude: 10,
horizontalAccuracy: accuracy, verticalAccuracy: 5,
timestamp: Date(timeIntervalSince1970: 1_700_000_000 - age)
)))
}