// // POISearchServiceTests.swift // SportsTimeTests // // TDD specification tests for POISearchService types. // import Testing import Foundation import CoreLocation @testable import SportsTime // MARK: - POI Tests @Suite("POI") struct POITests { // MARK: - Test Data private func makePOI(distanceMeters: Double) -> POISearchService.POI { POISearchService.POI( id: UUID(), name: "Test POI", category: .restaurant, coordinate: CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0), distanceMeters: distanceMeters, address: nil, mapItem: nil ) } // MARK: - Specification Tests: formattedDistance /// - Expected Behavior: Distances < 0.1 miles format as feet @Test("formattedDistance: short distances show feet") func formattedDistance_feet() { // 100 meters = ~328 feet = ~0.062 miles (less than 0.1) let poi = makePOI(distanceMeters: 100) let formatted = poi.formattedDistance #expect(formatted.contains("ft")) #expect(!formatted.contains("mi")) } /// - Expected Behavior: Distances >= 0.1 miles format as miles @Test("formattedDistance: longer distances show miles") func formattedDistance_miles() { // 500 meters = ~0.31 miles (greater than 0.1) let poi = makePOI(distanceMeters: 500) let formatted = poi.formattedDistance #expect(formatted.contains("mi")) #expect(!formatted.contains("ft")) } /// - Expected Behavior: Boundary at 0.1 miles (~161 meters) @Test("formattedDistance: boundary at 0.1 miles") func formattedDistance_boundary() { // 0.1 miles = ~161 meters let justUnderPOI = makePOI(distanceMeters: 160) // Just under 0.1 miles let justOverPOI = makePOI(distanceMeters: 162) // Just over 0.1 miles #expect(justUnderPOI.formattedDistance.contains("ft")) #expect(justOverPOI.formattedDistance.contains("mi")) } /// - Expected Behavior: Zero distance formats correctly @Test("formattedDistance: handles zero distance") func formattedDistance_zero() { let poi = makePOI(distanceMeters: 0) let formatted = poi.formattedDistance #expect(formatted.contains("0") || formatted.contains("ft")) } /// - Expected Behavior: Large distance formats correctly @Test("formattedDistance: handles large distance") func formattedDistance_large() { // 5000 meters = ~3.1 miles let poi = makePOI(distanceMeters: 5000) let formatted = poi.formattedDistance #expect(formatted.contains("mi")) #expect(formatted.contains("3.1") || formatted.contains("3.") || Double(formatted.replacingOccurrences(of: " mi", with: ""))! > 3.0) } // MARK: - Invariant Tests /// - Invariant: formattedDistance always contains a unit @Test("Invariant: formattedDistance always has unit") func invariant_formattedDistanceHasUnit() { let testDistances: [Double] = [0, 50, 100, 160, 162, 500, 1000, 5000] for distance in testDistances { let poi = makePOI(distanceMeters: distance) let formatted = poi.formattedDistance #expect(formatted.contains("ft") || formatted.contains("mi")) } } } // MARK: - POICategory Tests @Suite("POICategory") struct POICategoryTests { // MARK: - Specification Tests: displayName /// - Expected Behavior: Each category has a human-readable display name @Test("displayName: returns readable name") func displayName_readable() { #expect(POISearchService.POICategory.restaurant.displayName == "Restaurant") #expect(POISearchService.POICategory.bar.displayName == "Bar") #expect(POISearchService.POICategory.coffee.displayName == "Coffee") #expect(POISearchService.POICategory.hotel.displayName == "Hotel") #expect(POISearchService.POICategory.parking.displayName == "Parking") #expect(POISearchService.POICategory.attraction.displayName == "Attraction") #expect(POISearchService.POICategory.entertainment.displayName == "Entertainment") } // MARK: - Specification Tests: iconName /// - Expected Behavior: Each category has a valid SF Symbol name @Test("iconName: returns SF Symbol name") func iconName_sfSymbol() { #expect(POISearchService.POICategory.restaurant.iconName == "fork.knife") #expect(POISearchService.POICategory.bar.iconName == "wineglass.fill") #expect(POISearchService.POICategory.coffee.iconName == "cup.and.saucer.fill") #expect(POISearchService.POICategory.hotel.iconName == "bed.double.fill") #expect(POISearchService.POICategory.parking.iconName == "car.fill") #expect(POISearchService.POICategory.attraction.iconName == "star.fill") #expect(POISearchService.POICategory.entertainment.iconName == "theatermasks.fill") } // MARK: - Specification Tests: searchQuery /// - Expected Behavior: Each category has a search-friendly query string @Test("searchQuery: returns search string") func searchQuery_searchString() { #expect(POISearchService.POICategory.restaurant.searchQuery == "restaurants") #expect(POISearchService.POICategory.bar.searchQuery == "bars") #expect(POISearchService.POICategory.coffee.searchQuery == "coffee shops") #expect(POISearchService.POICategory.hotel.searchQuery == "hotels") #expect(POISearchService.POICategory.parking.searchQuery == "parking") #expect(POISearchService.POICategory.attraction.searchQuery == "tourist attractions") #expect(POISearchService.POICategory.entertainment.searchQuery == "entertainment") } // MARK: - Invariant Tests /// - Invariant: All categories have non-empty properties @Test("Invariant: all categories have non-empty properties") func invariant_nonEmptyProperties() { for category in POISearchService.POICategory.allCases { #expect(!category.displayName.isEmpty) #expect(!category.iconName.isEmpty) #expect(!category.searchQuery.isEmpty) } } /// - Invariant: CaseIterable includes all cases @Test("Invariant: CaseIterable includes all cases") func invariant_allCasesIncluded() { #expect(POISearchService.POICategory.allCases.count == 7) #expect(POISearchService.POICategory.allCases.contains(.restaurant)) #expect(POISearchService.POICategory.allCases.contains(.bar)) #expect(POISearchService.POICategory.allCases.contains(.coffee)) #expect(POISearchService.POICategory.allCases.contains(.hotel)) #expect(POISearchService.POICategory.allCases.contains(.parking)) #expect(POISearchService.POICategory.allCases.contains(.attraction)) #expect(POISearchService.POICategory.allCases.contains(.entertainment)) } } // MARK: - POISearchError Tests @Suite("POISearchError") struct POISearchErrorTests { // MARK: - Specification Tests: errorDescription /// - Expected Behavior: searchFailed includes the reason @Test("errorDescription: searchFailed includes reason") func errorDescription_searchFailed() { let error = POISearchService.POISearchError.searchFailed("Network error") #expect(error.errorDescription != nil) #expect(error.errorDescription!.contains("Network error") || error.errorDescription!.lowercased().contains("search")) } /// - Expected Behavior: noResults explains no POIs found @Test("errorDescription: noResults mentions no results") func errorDescription_noResults() { let error = POISearchService.POISearchError.noResults #expect(error.errorDescription != nil) #expect(error.errorDescription!.lowercased().contains("no") || error.errorDescription!.lowercased().contains("found")) } // MARK: - Invariant Tests /// - Invariant: All errors have non-empty descriptions @Test("Invariant: all errors have descriptions") func invariant_allHaveDescriptions() { let errors: [POISearchService.POISearchError] = [ .searchFailed("test"), .noResults ] for error in errors { #expect(error.errorDescription != nil) #expect(!error.errorDescription!.isEmpty) } } }