Files
Sportstime/SportsTimeTests/SportsTimeTests.swift
Trey t 92d808caf5 Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements:
- Add StadiumVisit and Achievement SwiftData models
- Create Progress tab with interactive map view
- Implement photo-based visit import with GPS/date matching
- Add achievement badges (count-based, regional, journey)
- Create shareable progress cards for social media
- Add canonical data infrastructure (stadium identities, team aliases)
- Implement score resolution from free APIs (MLB, NBA, NHL stats)

UI Improvements:
- Add ThemedSpinner and ThemedSpinnerCompact components
- Replace all ProgressView() with themed spinners throughout app
- Fix sport selection state not persisting when navigating away

Bug Fixes:
- Fix Coast to Coast trips showing only 1 city (validation issue)
- Fix stadium progress showing 0/0 (filtering issue)
- Remove "Stadium Quest" title from progress view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:20:03 -06:00

417 lines
18 KiB
Swift

//
// SportsTimeTests.swift
// SportsTimeTests
//
// Created by Trey Tartt on 1/6/26.
//
import Testing
@testable import SportsTime
import Foundation
// MARK: - DayCard Tests
/// Tests for DayCard conflict detection and display logic
struct DayCardTests {
// MARK: - Test Data Helpers
private func makeGame(id: UUID, dateTime: Date, stadiumId: UUID, sport: Sport = .mlb) -> Game {
Game(
id: id,
homeTeamId: UUID(),
awayTeamId: UUID(),
stadiumId: stadiumId,
dateTime: dateTime,
sport: sport,
season: "2026"
)
}
private func makeRichGame(game: Game, homeTeamName: String = "Home", awayTeamName: String = "Away") -> RichGame {
let stadiumId = game.stadiumId
let homeTeam = Team(
id: game.homeTeamId,
name: homeTeamName,
abbreviation: "HOM",
sport: game.sport,
city: "Home City",
stadiumId: stadiumId
)
let awayTeam = Team(
id: game.awayTeamId,
name: awayTeamName,
abbreviation: "AWY",
sport: game.sport,
city: "Away City",
stadiumId: UUID()
)
let stadium = Stadium(
id: stadiumId,
name: "Stadium",
city: "City",
state: "ST",
latitude: 40.0,
longitude: -100.0,
capacity: 40000,
sport: game.sport
)
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
private func makeStop(
city: String,
arrivalDate: Date,
departureDate: Date,
games: [UUID]
) -> TripStop {
TripStop(
stopNumber: 1,
city: city,
state: "ST",
arrivalDate: arrivalDate,
departureDate: departureDate,
games: games
)
}
// MARK: - Conflict Detection Tests
@Test("DayCard with specificStop shows only that stop's games")
func dayCard_WithSpecificStop_ShowsOnlyThatStopsGames() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let denverGameId = UUID()
let atlantaGameId = UUID()
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 10, second: 0, of: apr4)!
let atlantaGameTime = Calendar.current.date(bySettingHour: 23, minute: 15, second: 0, of: apr4)!
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
let atlantaGame = makeGame(id: atlantaGameId, dateTime: atlantaGameTime, stadiumId: UUID())
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [atlantaGameId])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop], travelSegments: [])
let games: [UUID: RichGame] = [
denverGameId: makeRichGame(game: denverGame),
atlantaGameId: makeRichGame(game: atlantaGame)
]
// Denver card shows only Denver game
let denverCard = DayCard(day: day, games: games, specificStop: denverStop)
#expect(denverCard.gamesOnThisDay.count == 1)
#expect(denverCard.gamesOnThisDay.first?.game.id == denverGameId)
#expect(denverCard.primaryCityForDay == "Denver")
// Atlanta card shows only Atlanta game
let atlantaCard = DayCard(day: day, games: games, specificStop: atlantaStop)
#expect(atlantaCard.gamesOnThisDay.count == 1)
#expect(atlantaCard.gamesOnThisDay.first?.game.id == atlantaGameId)
#expect(atlantaCard.primaryCityForDay == "Atlanta")
}
@Test("DayCard shows conflict warning when conflictInfo provided")
func dayCard_ShowsConflictWarning_WhenConflictInfoProvided() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let denverGameId = UUID()
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 10, second: 0, of: apr4)!
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop], travelSegments: [])
let games: [UUID: RichGame] = [denverGameId: makeRichGame(game: denverGame)]
let conflictInfo = DayConflictInfo(
hasConflict: true,
conflictingStops: [denverStop, atlantaStop],
conflictingCities: ["Denver", "Atlanta"]
)
let dayCard = DayCard(day: day, games: games, specificStop: denverStop, conflictInfo: conflictInfo)
#expect(dayCard.hasConflict == true)
#expect(dayCard.otherConflictingCities == ["Atlanta"])
}
@Test("DayCard without conflict shows no warning")
func dayCard_WithoutConflict_ShowsNoWarning() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let gameId = UUID()
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
let stop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [gameId])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
let dayCard = DayCard(day: day, games: games)
#expect(dayCard.hasConflict == false)
#expect(dayCard.otherConflictingCities.isEmpty)
}
@Test("DayConflictInfo lists all conflicting cities in warning message")
func dayConflictInfo_ListsAllCitiesInWarningMessage() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let chicagoStop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let conflictInfo = DayConflictInfo(
hasConflict: true,
conflictingStops: [denverStop, atlantaStop, chicagoStop],
conflictingCities: ["Denver", "Atlanta", "Chicago"]
)
#expect(conflictInfo.hasConflict == true)
#expect(conflictInfo.conflictingCities.count == 3)
#expect(conflictInfo.warningMessage.contains("Denver"))
#expect(conflictInfo.warningMessage.contains("Atlanta"))
#expect(conflictInfo.warningMessage.contains("Chicago"))
}
@Test("otherConflictingCities excludes current city")
func otherConflictingCities_ExcludesCurrentCity() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let denverGameId = UUID()
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 0, second: 0, of: apr4)!
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let chicagoStop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop, chicagoStop], travelSegments: [])
let games: [UUID: RichGame] = [denverGameId: makeRichGame(game: denverGame)]
let conflictInfo = DayConflictInfo(
hasConflict: true,
conflictingStops: [denverStop, atlantaStop, chicagoStop],
conflictingCities: ["Denver", "Atlanta", "Chicago"]
)
let dayCard = DayCard(day: day, games: games, specificStop: denverStop, conflictInfo: conflictInfo)
// Should exclude Denver (current city), include Atlanta and Chicago
#expect(dayCard.otherConflictingCities.count == 2)
#expect(dayCard.otherConflictingCities.contains("Atlanta"))
#expect(dayCard.otherConflictingCities.contains("Chicago"))
#expect(!dayCard.otherConflictingCities.contains("Denver"))
}
// MARK: - Basic DayCard Tests
@Test("DayCard handles single stop correctly")
func dayCard_HandlesSingleStop_Correctly() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let gameId = UUID()
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
let stop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [gameId])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
let dayCard = DayCard(day: day, games: games)
#expect(dayCard.gamesOnThisDay.count == 1)
#expect(dayCard.primaryCityForDay == "Chicago")
#expect(dayCard.hasConflict == false)
}
@Test("DayCard handles no stops gracefully")
func dayCard_HandlesNoStops_Gracefully() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [], travelSegments: [])
let dayCard = DayCard(day: day, games: [:])
#expect(dayCard.gamesOnThisDay.isEmpty)
#expect(dayCard.primaryCityForDay == nil)
#expect(dayCard.hasConflict == false)
}
@Test("DayCard handles stop with no games on the specific day")
func dayCard_HandlesStopWithNoGamesOnDay() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let apr6 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 6))!
// Game is on Apr 5, but we're looking at Apr 4
let gameId = UUID()
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr5)!
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
// Stop spans Apr 4-6, but game is on Apr 5
let stop = makeStop(city: "Boston", arrivalDate: apr4, departureDate: apr6, games: [gameId])
// Looking at Apr 4 (arrival day, no game)
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
let dayCard = DayCard(day: day, games: games)
// No games should show on Apr 4 even though the stop has a game (it's on Apr 5)
#expect(dayCard.gamesOnThisDay.isEmpty, "No games on Apr 4, game is on Apr 5")
#expect(dayCard.primaryCityForDay == "Boston", "Still shows the city even without games")
}
@Test("DayCard handles multiple games at same stop on same day (doubleheader)")
func dayCard_HandlesMultipleGamesAtSameStop() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
// Two games in same city on same day (doubleheader)
let game1Id = UUID()
let game2Id = UUID()
let game1Time = Calendar.current.date(bySettingHour: 13, minute: 0, second: 0, of: apr4)!
let game2Time = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
let game1 = makeGame(id: game1Id, dateTime: game1Time, stadiumId: UUID())
let game2 = makeGame(id: game2Id, dateTime: game2Time, stadiumId: UUID())
let stop = makeStop(city: "New York", arrivalDate: apr4, departureDate: apr5, games: [game1Id, game2Id])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
let games: [UUID: RichGame] = [
game1Id: makeRichGame(game: game1),
game2Id: makeRichGame(game: game2)
]
let dayCard = DayCard(day: day, games: games)
#expect(dayCard.gamesOnThisDay.count == 2, "Should show both games from same city")
#expect(dayCard.hasConflict == false, "Same city doubleheader is not a conflict")
}
@Test("DayCard selects stop with game when first stop has no game on that day")
func dayCard_SelectsStopWithGame_WhenFirstStopHasNoGame() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
// First stop has game on different day
let firstStopGameId = UUID()
let firstStopGameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr5)!
let firstStopGame = makeGame(id: firstStopGameId, dateTime: firstStopGameTime, stadiumId: UUID())
// Second stop has game on Apr 4
let secondStopGameId = UUID()
let secondStopGameTime = Calendar.current.date(bySettingHour: 20, minute: 0, second: 0, of: apr4)!
let secondStopGame = makeGame(id: secondStopGameId, dateTime: secondStopGameTime, stadiumId: UUID())
let firstStop = makeStop(city: "Philadelphia", arrivalDate: apr4, departureDate: apr5, games: [firstStopGameId])
let secondStop = makeStop(city: "Baltimore", arrivalDate: apr4, departureDate: apr5, games: [secondStopGameId])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [firstStop, secondStop], travelSegments: [])
let games: [UUID: RichGame] = [
firstStopGameId: makeRichGame(game: firstStopGame),
secondStopGameId: makeRichGame(game: secondStopGame)
]
let dayCard = DayCard(day: day, games: games)
// Should select Baltimore (has game on Apr 4) not Philadelphia (game on Apr 5)
#expect(dayCard.gamesOnThisDay.count == 1)
#expect(dayCard.gamesOnThisDay.first?.game.id == secondStopGameId)
#expect(dayCard.primaryCityForDay == "Baltimore")
}
// MARK: - DayConflictInfo Tests
@Test("DayConflictInfo with no conflict has empty warning")
func dayConflictInfo_NoConflict_EmptyWarning() {
let conflictInfo = DayConflictInfo(
hasConflict: false,
conflictingStops: [],
conflictingCities: []
)
#expect(conflictInfo.hasConflict == false)
#expect(conflictInfo.warningMessage.isEmpty)
}
}
// MARK: - Duplicate Game ID Regression Tests
/// Tests for handling duplicate game IDs without crashing (regression test for fatal error)
struct DuplicateGameIdTests {
private func makeStadium(sport: Sport = .mlb) -> Stadium {
Stadium(
id: UUID(),
name: "Test Stadium",
city: "Test City",
state: "TS",
latitude: 40.0,
longitude: -100.0,
capacity: 40000,
sport: sport
)
}
private func makeTeam(sport: Sport = .mlb, stadiumId: UUID) -> Team {
Team(
id: UUID(),
name: "Test Team",
abbreviation: "TST",
sport: sport,
city: "Test City",
stadiumId: stadiumId
)
}
private func makeGame(id: UUID, homeTeamId: UUID, awayTeamId: UUID, stadiumId: UUID, dateTime: Date) -> Game {
Game(
id: id,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
stadiumId: stadiumId,
dateTime: dateTime,
sport: .mlb,
season: "2026"
)
}
// Note: GameCandidate test removed - type no longer exists after planning engine refactor
@Test("Duplicate games are deduplicated at load time")
func gamesArray_DeduplicatesById() {
// Simulate the deduplication logic used in StubDataProvider
let gameId = UUID()
let dateTime = Date()
let game1 = makeGame(id: gameId, homeTeamId: UUID(), awayTeamId: UUID(), stadiumId: UUID(), dateTime: dateTime)
let game2 = makeGame(id: gameId, homeTeamId: UUID(), awayTeamId: UUID(), stadiumId: UUID(), dateTime: dateTime.addingTimeInterval(3600))
let games = [game1, game2]
// Deduplication logic from StubDataProvider
var seenIds = Set<UUID>()
let uniqueGames = games.filter { game in
if seenIds.contains(game.id) {
return false
}
seenIds.insert(game.id)
return true
}
#expect(uniqueGames.count == 1)
#expect(uniqueGames.first?.dateTime == game1.dateTime, "First occurrence should be kept")
}
}