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>
337 lines
12 KiB
Swift
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")
|
|
}
|
|
}
|