- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search - Add geographic diversity to route selection (returns routes from distinct regions) - Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes - Simplify itinerary display: separate games and travel segments by date - Remove complex ItineraryDay bundling, query games/travel directly per day - Update ScenarioA/B/C planners to use GameDAGRouter - Add new test suites for planners and travel estimator 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
415 lines
17 KiB
Swift
415 lines
17 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
|
|
)
|
|
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() -> Stadium {
|
|
Stadium(
|
|
id: UUID(),
|
|
name: "Test Stadium",
|
|
city: "Test City",
|
|
state: "TS",
|
|
latitude: 40.0,
|
|
longitude: -100.0,
|
|
capacity: 40000
|
|
)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|