Files
Sportstime/SportsTimeTests/Export/POISearchServiceTests.swift
Trey t 999b5a1190 Fix game times with UTC data, restructure schedule by date
- Update games_canonical.json to use ISO 8601 UTC timestamps (game_datetime_utc)
- Fix BootstrapService timezone-aware parsing for venue-local fallback
- Fix thread-unsafe shared DateFormatter in RichGame local time display
- Bump SchemaVersion to 4 to force re-bootstrap with correct UTC data
- Restructure schedule view: group by date instead of sport, with sport
  icons on each row and date section headers showing game counts
- Fix schedule row backgrounds using Theme.cardBackground instead of black
- Sort games by UTC time with local-time tiebreaker for same-instant games

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:43:39 -06:00

205 lines
7.6 KiB
Swift

//
// 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.attraction.displayName == "Attraction")
#expect(POISearchService.POICategory.entertainment.displayName == "Entertainment")
#expect(POISearchService.POICategory.nightlife.displayName == "Nightlife")
#expect(POISearchService.POICategory.museum.displayName == "Museum")
}
// 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.attraction.iconName == "star.fill")
#expect(POISearchService.POICategory.entertainment.iconName == "theatermasks.fill")
#expect(POISearchService.POICategory.nightlife.iconName == "moon.stars.fill")
#expect(POISearchService.POICategory.museum.iconName == "building.columns.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.attraction.searchQuery == "tourist attractions")
#expect(POISearchService.POICategory.entertainment.searchQuery == "entertainment")
#expect(POISearchService.POICategory.nightlife.searchQuery == "bars nightlife")
#expect(POISearchService.POICategory.museum.searchQuery == "museums")
}
// 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 == 5)
#expect(POISearchService.POICategory.allCases.contains(.restaurant))
#expect(POISearchService.POICategory.allCases.contains(.attraction))
#expect(POISearchService.POICategory.allCases.contains(.entertainment))
#expect(POISearchService.POICategory.allCases.contains(.nightlife))
#expect(POISearchService.POICategory.allCases.contains(.museum))
}
}
// 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)
}
}
}