Files
Sportstime/SportsTimeTests/Services/LocationServiceTests.swift
Trey T a6f538dfed Audit and fix 52 test correctness issues across 22 files
Systematic audit of 1,191 tests found tests written to pass rather than
verify correctness. Key fixes:

Infrastructure:
- TestClock: fixed timezone from .current to America/New_York (deterministic)
- TestFixtures: added 1.3x road routing factor to match production
- ItineraryTestHelpers: real per-city coordinates instead of hardcoded (40,-80)

Planning tests:
- Added missing Scenario E factory dispatch tests
- Tightened 12 loose assertions (>= 1 → == 8.0, > 0 → range checks)
- Fixed 4 no-op tests that accepted both success and failure
- Fixed wrong repeat-city invariant (was checking same-day, not different-day)
- Fixed tautological assertion in missing-stadium edge case

Services/Domain/Export tests:
- Replaced 4 placeholder tests (#expect(true)) with real assertions
- Fixed tautological assertions in POISearchServiceTests
- Fixed Chicago coordinate in RegionMapSelectorTests (-89 → -87.6553)
- Added sort order verification to ItineraryRowFlatteningTests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:00:46 -05:00

337 lines
12 KiB
Swift

//
// LocationServiceTests.swift
// SportsTimeTests
//
// TDD specification tests for LocationService types.
//
import Testing
import Foundation
import CoreLocation
@testable import SportsTime
// MARK: - RouteInfo Tests
@Suite("RouteInfo")
struct RouteInfoTests {
// MARK: - Specification Tests: distanceMiles
/// - Expected Behavior: Converts meters to miles (1 mile = 1609.34 meters)
@Test("distanceMiles: converts meters to miles")
func distanceMiles_conversion() {
let route = RouteInfo(distance: 1609.34, expectedTravelTime: 0, polyline: nil)
#expect(abs(route.distanceMiles - 1.0) < 0.001)
}
@Test("distanceMiles: 0 meters returns 0 miles")
func distanceMiles_zero() {
let route = RouteInfo(distance: 0, expectedTravelTime: 0, polyline: nil)
#expect(route.distanceMiles == 0)
}
@Test("distanceMiles: 100 miles distance")
func distanceMiles_hundredMiles() {
let meters = 100 * 1609.34
let route = RouteInfo(distance: meters, expectedTravelTime: 0, polyline: nil)
#expect(abs(route.distanceMiles - 100.0) < 0.01)
}
// MARK: - Specification Tests: travelTimeHours
/// - Expected Behavior: Converts seconds to hours (1 hour = 3600 seconds)
@Test("travelTimeHours: converts seconds to hours")
func travelTimeHours_conversion() {
let route = RouteInfo(distance: 0, expectedTravelTime: 3600, polyline: nil)
#expect(route.travelTimeHours == 1.0)
}
@Test("travelTimeHours: 0 seconds returns 0 hours")
func travelTimeHours_zero() {
let route = RouteInfo(distance: 0, expectedTravelTime: 0, polyline: nil)
#expect(route.travelTimeHours == 0)
}
@Test("travelTimeHours: 90 minutes returns 1.5 hours")
func travelTimeHours_ninetyMinutes() {
let route = RouteInfo(distance: 0, expectedTravelTime: 5400, polyline: nil)
#expect(route.travelTimeHours == 1.5)
}
// MARK: - Invariant Tests
/// - Invariant: distanceMiles >= 0
@Test("Invariant: distanceMiles is non-negative")
func invariant_distanceMilesNonNegative() {
let testDistances: [Double] = [0, 100, 1000, 100000]
for distance in testDistances {
let route = RouteInfo(distance: distance, expectedTravelTime: 0, polyline: nil)
#expect(route.distanceMiles >= 0)
}
}
/// - Invariant: travelTimeHours >= 0
@Test("Invariant: travelTimeHours is non-negative")
func invariant_travelTimeHoursNonNegative() {
let testTimes: [Double] = [0, 60, 3600, 36000]
for time in testTimes {
let route = RouteInfo(distance: 0, expectedTravelTime: time, polyline: nil)
#expect(route.travelTimeHours >= 0)
}
}
/// - Invariant: distanceMiles = distance * 0.000621371
@Test("Invariant: distanceMiles uses correct conversion factor")
func invariant_distanceMilesConversionFactor() {
let distance = 5000.0
let route = RouteInfo(distance: distance, expectedTravelTime: 0, polyline: nil)
let expected = distance * 0.000621371
#expect(abs(route.distanceMiles - expected) < 0.0001)
}
/// - Invariant: travelTimeHours = expectedTravelTime / 3600
@Test("Invariant: travelTimeHours uses correct conversion factor")
func invariant_travelTimeHoursConversionFactor() {
let time = 7200.0
let route = RouteInfo(distance: 0, expectedTravelTime: time, polyline: nil)
let expected = time / 3600.0
#expect(route.travelTimeHours == expected)
}
}
// MARK: - LocationSearchResult Tests
@Suite("LocationSearchResult")
struct LocationSearchResultTests {
// MARK: - Test Data
private func makeResult(
name: String = "Stadium",
address: String = "123 Main St"
) -> LocationSearchResult {
LocationSearchResult(
name: name,
address: address,
coordinate: CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0)
)
}
// MARK: - Specification Tests: displayName
/// - Expected Behavior: Combines name and address when different
@Test("displayName: combines name and address when different")
func displayName_combined() {
let result = makeResult(name: "Yankee Stadium", address: "Bronx, NY")
#expect(result.displayName == "Yankee Stadium, Bronx, NY")
}
/// - Expected Behavior: Returns just name when address is empty
@Test("displayName: returns name when address is empty")
func displayName_emptyAddress() {
let result = makeResult(name: "Yankee Stadium", address: "")
#expect(result.displayName == "Yankee Stadium")
}
/// - Expected Behavior: Returns just name when address equals name
@Test("displayName: returns name when address equals name")
func displayName_sameAsName() {
let result = makeResult(name: "Yankee Stadium", address: "Yankee Stadium")
#expect(result.displayName == "Yankee Stadium")
}
// MARK: - Specification Tests: toLocationInput
@Test("toLocationInput: preserves name")
func toLocationInput_preservesName() {
let result = makeResult(name: "Test Venue", address: "123 Main St")
let input = result.toLocationInput()
#expect(input.name == "Test Venue")
}
@Test("toLocationInput: preserves coordinate")
func toLocationInput_preservesCoordinate() {
let result = LocationSearchResult(
name: "Test",
address: "",
coordinate: CLLocationCoordinate2D(latitude: 40.5, longitude: -73.5)
)
let input = result.toLocationInput()
#expect(input.coordinate?.latitude == 40.5)
#expect(input.coordinate?.longitude == -73.5)
}
@Test("toLocationInput: address becomes nil when empty")
func toLocationInput_emptyAddressNil() {
let result = makeResult(name: "Test", address: "")
let input = result.toLocationInput()
#expect(input.address == nil)
}
@Test("toLocationInput: preserves non-empty address")
func toLocationInput_preservesAddress() {
let result = makeResult(name: "Test", address: "123 Main St")
let input = result.toLocationInput()
#expect(input.address == "123 Main St")
}
// MARK: - Invariant Tests
/// - Invariant: displayName always contains name
@Test("Invariant: displayName contains name")
func invariant_displayNameContainsName() {
let testCases = [
("Stadium A", "Address 1"),
("Stadium B", ""),
("Stadium C", "Stadium C")
]
for (name, address) in testCases {
let result = makeResult(name: name, address: address)
#expect(result.displayName.contains(name))
}
}
/// - Invariant: Each instance has unique id
@Test("Invariant: each instance has unique id")
func invariant_uniqueId() {
let result1 = makeResult()
let result2 = makeResult()
#expect(result1.id != result2.id)
}
}
// MARK: - LocationError Tests
@Suite("LocationError")
struct LocationErrorTests {
// MARK: - Specification Tests: errorDescription
@Test("errorDescription: geocodingFailed has description")
func errorDescription_geocodingFailed() {
let error = LocationError.geocodingFailed
#expect(error.errorDescription != nil)
#expect(!error.errorDescription!.isEmpty)
}
@Test("errorDescription: routeNotFound has description")
func errorDescription_routeNotFound() {
let error = LocationError.routeNotFound
#expect(error.errorDescription != nil)
#expect(!error.errorDescription!.isEmpty)
}
@Test("errorDescription: permissionDenied has description")
func errorDescription_permissionDenied() {
let error = LocationError.permissionDenied
#expect(error.errorDescription != nil)
#expect(!error.errorDescription!.isEmpty)
}
// MARK: - Invariant Tests
/// - Invariant: All errors have distinct descriptions
@Test("Invariant: all errors have distinct descriptions")
func invariant_distinctDescriptions() {
let errors: [LocationError] = [.geocodingFailed, .routeNotFound, .permissionDenied]
let descriptions = errors.compactMap { $0.errorDescription }
#expect(descriptions.count == errors.count)
#expect(Set(descriptions).count == descriptions.count)
}
}
// MARK: - LocationPermissionManager Computed Properties Tests
@Suite("LocationPermissionManager Properties")
struct LocationPermissionManagerPropertiesTests {
// MARK: - Specification Tests: isAuthorized
/// - Expected Behavior: true when authorizedWhenInUse or authorizedAlways
/// Tests the isAuthorized logic: status == .authorizedWhenInUse || status == .authorizedAlways
@Test("isAuthorized: logic based on CLAuthorizationStatus")
func isAuthorized_logic() {
// Mirror the production logic from LocationPermissionManager.isAuthorized
func isAuthorized(_ status: CLAuthorizationStatus) -> Bool {
status == .authorizedWhenInUse || status == .authorizedAlways
}
#expect(isAuthorized(.authorizedWhenInUse) == true)
#expect(isAuthorized(.authorizedAlways) == true)
#expect(isAuthorized(.notDetermined) == false)
#expect(isAuthorized(.denied) == false)
#expect(isAuthorized(.restricted) == false)
}
// MARK: - Specification Tests: needsPermission
/// - Expected Behavior: true only when notDetermined
/// Tests the needsPermission logic: status == .notDetermined
@Test("needsPermission: true only when notDetermined")
func needsPermission_logic() {
func needsPermission(_ status: CLAuthorizationStatus) -> Bool {
status == .notDetermined
}
#expect(needsPermission(.notDetermined) == true)
#expect(needsPermission(.denied) == false)
#expect(needsPermission(.restricted) == false)
#expect(needsPermission(.authorizedWhenInUse) == false)
#expect(needsPermission(.authorizedAlways) == false)
}
// MARK: - Specification Tests: isDenied
/// - Expected Behavior: true when denied or restricted
/// Tests the isDenied logic: status == .denied || status == .restricted
@Test("isDenied: true when denied or restricted")
func isDenied_logic() {
func isDenied(_ status: CLAuthorizationStatus) -> Bool {
status == .denied || status == .restricted
}
#expect(isDenied(.denied) == true)
#expect(isDenied(.restricted) == true)
#expect(isDenied(.notDetermined) == false)
#expect(isDenied(.authorizedWhenInUse) == false)
#expect(isDenied(.authorizedAlways) == false)
}
// MARK: - Specification Tests: statusMessage
/// - Expected Behavior: Each status has a user-friendly message
/// Tests the statusMessage logic: every CLAuthorizationStatus maps to a non-empty string
@Test("statusMessage: all statuses have messages")
func statusMessage_allHaveMessages() {
func statusMessage(_ status: CLAuthorizationStatus) -> String {
switch status {
case .notDetermined:
return "Location access helps find nearby stadiums and optimize your route."
case .restricted:
return "Location access is restricted on this device."
case .denied:
return "Location access was denied. Enable it in Settings to use this feature."
case .authorizedAlways, .authorizedWhenInUse:
return "Location access granted."
@unknown default:
return "Unknown location status."
}
}
let allStatuses: [CLAuthorizationStatus] = [
.notDetermined, .restricted, .denied, .authorizedWhenInUse, .authorizedAlways
]
for status in allStatuses {
let message = statusMessage(status)
#expect(!message.isEmpty, "Status \(status.rawValue) should have a non-empty message")
}
// Verify distinct messages for distinct status categories
let messages = Set(allStatuses.map { statusMessage($0) })
#expect(messages.count >= 4, "Should have at least 4 distinct messages")
}
}