// // 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") } }