Files
Sportstime/SportsTimeTests/Planning/TeamFirstIntegrationTests.swift
2026-04-03 15:31:52 -05:00

600 lines
21 KiB
Swift

//
// TeamFirstIntegrationTests.swift
// SportsTimeTests
//
// Integration tests for Team-First planning mode.
//
// These tests verify the end-to-end flow from team selection
// to route generation, ensuring all components work together.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("TeamFirst Integration")
struct TeamFirstIntegrationTests {
// MARK: - Test Data
private let planner = ScenarioEPlanner(currentDate: TestClock.now)
// MLB stadiums with realistic coordinates
private let yankeeStadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262)
private let fenwayParkCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let citizensBankCoord = CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665)
// MARK: - E3. Full Flow Integration Tests
@Test("Integration: 3 MLB teams returns top 10 routes")
func integration_3MLBTeams_returnsTop10Routes() {
let baseDate = TestClock.now
// Create realistic MLB stadiums
let yankeeStadium = Stadium(
id: "stadium_mlb_yankee_stadium",
name: "Yankee Stadium",
city: "New York",
state: "NY",
latitude: yankeeStadiumCoord.latitude,
longitude: yankeeStadiumCoord.longitude,
capacity: 47309,
sport: .mlb
)
let fenwayPark = Stadium(
id: "stadium_mlb_fenway_park",
name: "Fenway Park",
city: "Boston",
state: "MA",
latitude: fenwayParkCoord.latitude,
longitude: fenwayParkCoord.longitude,
capacity: 37755,
sport: .mlb
)
let citizensBank = Stadium(
id: "stadium_mlb_citizens_bank",
name: "Citizens Bank Park",
city: "Philadelphia",
state: "PA",
latitude: citizensBankCoord.latitude,
longitude: citizensBankCoord.longitude,
capacity: 42792,
sport: .mlb
)
// Create teams
let yankees = Team(
id: "team_mlb_nyy",
name: "Yankees",
abbreviation: "NYY",
sport: .mlb,
city: "New York",
stadiumId: "stadium_mlb_yankee_stadium"
)
let redsox = Team(
id: "team_mlb_bos",
name: "Red Sox",
abbreviation: "BOS",
sport: .mlb,
city: "Boston",
stadiumId: "stadium_mlb_fenway_park"
)
let phillies = Team(
id: "team_mlb_phi",
name: "Phillies",
abbreviation: "PHI",
sport: .mlb,
city: "Philadelphia",
stadiumId: "stadium_mlb_citizens_bank"
)
// Create games within a reasonable 6-day window (3 teams * 2 = 6 days)
// For the window algorithm to find valid windows, games must span at least 6 days
// Window check: windowEnd <= latestDay + 1, so with 6-day window from day 1,
// windowEnd = day 7, so latestDay must be >= day 6
// Day 1: Yankees home
// Day 3: Red Sox home
// Day 6: Phillies home (spans 6 days, window fits)
let calendar = TestClock.calendar
let day1 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day3 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 3))!
let day6 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 6))!
let yankeesGame = Game(
id: "game_mlb_2026_nyy_opp_0401",
homeTeamId: "team_mlb_nyy",
awayTeamId: "team_mlb_opp",
stadiumId: "stadium_mlb_yankee_stadium",
dateTime: day1,
sport: .mlb,
season: "2026",
isPlayoff: false
)
let redsoxGame = Game(
id: "game_mlb_2026_bos_opp_0403",
homeTeamId: "team_mlb_bos",
awayTeamId: "team_mlb_opp",
stadiumId: "stadium_mlb_fenway_park",
dateTime: day3,
sport: .mlb,
season: "2026",
isPlayoff: false
)
let philliesGame = Game(
id: "game_mlb_2026_phi_opp_0405",
homeTeamId: "team_mlb_phi",
awayTeamId: "team_mlb_opp",
stadiumId: "stadium_mlb_citizens_bank",
dateTime: day6,
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["team_mlb_nyy", "team_mlb_bos", "team_mlb_phi"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, redsoxGame, philliesGame],
teams: [
"team_mlb_nyy": yankees,
"team_mlb_bos": redsox,
"team_mlb_phi": phillies
],
stadiums: [
"stadium_mlb_yankee_stadium": yankeeStadium,
"stadium_mlb_fenway_park": fenwayPark,
"stadium_mlb_citizens_bank": citizensBank
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success with 3 MLB teams in drivable region")
return
}
// Verify we get routes (may be less than 10 due to limited game combinations)
#expect(!options.isEmpty, "Should return at least one route")
#expect(options.count <= 10, "Should return at most 10 routes")
}
@Test("Integration: each route visits all 3 stadiums")
func integration_eachRouteVisitsAll3Stadiums() {
let baseDate = TestClock.now
let yankeeStadium = makeStadium(
id: "yankee-stadium",
name: "Yankee Stadium",
city: "New York",
coordinate: yankeeStadiumCoord
)
let fenwayPark = makeStadium(
id: "fenway-park",
name: "Fenway Park",
city: "Boston",
coordinate: fenwayParkCoord
)
let citizensBank = makeStadium(
id: "citizens-bank",
name: "Citizens Bank Park",
city: "Philadelphia",
coordinate: citizensBankCoord
)
// Create multiple games per team to ensure routes can be found
var games: [Game] = []
// Yankees games
for dayOffset in [1, 7, 14] {
games.append(Game(
id: "yankees-\(dayOffset)",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "yankee-stadium",
dateTime: baseDate.addingTimeInterval(Double(86400 * dayOffset)),
sport: .mlb,
season: "2026",
isPlayoff: false
))
}
// Red Sox games
for dayOffset in [2, 8, 15] {
games.append(Game(
id: "redsox-\(dayOffset)",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "fenway-park",
dateTime: baseDate.addingTimeInterval(Double(86400 * dayOffset)),
sport: .mlb,
season: "2026",
isPlayoff: false
))
}
// Phillies games
for dayOffset in [3, 9, 16] {
games.append(Game(
id: "phillies-\(dayOffset)",
homeTeamId: "phillies",
awayTeamId: "opponent",
stadiumId: "citizens-bank",
dateTime: baseDate.addingTimeInterval(Double(86400 * dayOffset)),
sport: .mlb,
season: "2026",
isPlayoff: false
))
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox", "phillies"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox"),
"phillies": makeTeam(id: "phillies", name: "Phillies")
],
stadiums: [
"yankee-stadium": yankeeStadium,
"fenway-park": fenwayPark,
"citizens-bank": citizensBank
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success")
return
}
// Verify each route visits all 3 stadiums
for option in options {
let citiesVisited = Set(option.stops.map { $0.city })
#expect(citiesVisited.contains("New York"), "Every route must visit New York")
#expect(citiesVisited.contains("Boston"), "Every route must visit Boston")
#expect(citiesVisited.contains("Philadelphia"), "Every route must visit Philadelphia")
}
}
@Test("Integration: total duration within 6 days (teams x 2)")
func integration_totalDurationWithinLimit() {
let baseDate = TestClock.now
let yankeeStadium = makeStadium(
id: "yankee-stadium",
name: "Yankee Stadium",
city: "New York",
coordinate: yankeeStadiumCoord
)
let fenwayPark = makeStadium(
id: "fenway-park",
name: "Fenway Park",
city: "Boston",
coordinate: fenwayParkCoord
)
let citizensBank = makeStadium(
id: "citizens-bank",
name: "Citizens Bank Park",
city: "Philadelphia",
coordinate: citizensBankCoord
)
// Create games that fit within a 6-day window
// For 3 teams, window = 6 days. Games must span at least 6 days.
let calendar = TestClock.calendar
let day1 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day3 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 3))!
let day6 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 6))!
let yankeesGame = Game(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "yankee-stadium",
dateTime: day1,
sport: .mlb,
season: "2026",
isPlayoff: false
)
let redsoxGame = Game(
id: "redsox-home",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "fenway-park",
dateTime: day3,
sport: .mlb,
season: "2026",
isPlayoff: false
)
let philliesGame = Game(
id: "phillies-home",
homeTeamId: "phillies",
awayTeamId: "opponent",
stadiumId: "citizens-bank",
dateTime: day6,
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox", "phillies"]
)
// 3 teams * 2 = 6 days max window
let maxDays = prefs.teamFirstMaxDays
#expect(maxDays == 6, "Window should be 6 days for 3 teams")
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, redsoxGame, philliesGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox"),
"phillies": makeTeam(id: "phillies", name: "Phillies")
],
stadiums: [
"yankee-stadium": yankeeStadium,
"fenway-park": fenwayPark,
"citizens-bank": citizensBank
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success")
return
}
// Verify each route's duration is reasonable
for option in options {
guard let firstStop = option.stops.first,
let lastStop = option.stops.last else {
continue
}
let calendar = TestClock.calendar
let tripDays = calendar.dateComponents(
[.day],
from: calendar.startOfDay(for: firstStop.arrivalDate),
to: calendar.startOfDay(for: lastStop.departureDate)
).day ?? 0
// Trip duration should be within the window (allowing +1 for same-day start/end)
#expect(tripDays <= maxDays, "Trip duration (\(tripDays) days) should not exceed window (\(maxDays) days)")
}
}
@Test("Integration: factory selects ScenarioEPlanner for teamFirst mode")
func integration_factorySelectsScenarioEPlanner() {
let baseDate = TestClock.now
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["team1", "team2"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: [:]
)
let scenario = ScenarioPlannerFactory.classify(request)
#expect(scenario == .scenarioE, "Should classify as scenarioE for teamFirst mode with 2+ teams")
let planner = ScenarioPlannerFactory.planner(for: request)
#expect(planner is ScenarioEPlanner, "Should return ScenarioEPlanner for teamFirst mode")
}
@Test("Integration: factory requires 2+ teams for ScenarioE")
func integration_factoryRequires2TeamsForScenarioE() {
let baseDate = TestClock.now
// With only 1 team, should NOT select ScenarioE
var prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["team1"] // Only 1 team
)
var request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: [:]
)
var scenario = ScenarioPlannerFactory.classify(request)
#expect(scenario != .scenarioE, "Should NOT classify as scenarioE with only 1 team")
// With 0 teams, should also NOT select ScenarioE
prefs.selectedTeamIds = []
request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: [:]
)
scenario = ScenarioPlannerFactory.classify(request)
#expect(scenario != .scenarioE, "Should NOT classify as scenarioE with 0 teams")
}
@Test("Integration: realistic east coast trip with 4 teams")
func integration_realisticEastCoastTrip() {
let baseDate = TestClock.now
// East coast stadiums (NYC, Boston, Philly, Baltimore)
let yankeeStadium = makeStadium(
id: "yankee-stadium",
name: "Yankee Stadium",
city: "New York",
coordinate: CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262)
)
let fenwayPark = makeStadium(
id: "fenway-park",
name: "Fenway Park",
city: "Boston",
coordinate: CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
)
let citizensBank = makeStadium(
id: "citizens-bank",
name: "Citizens Bank Park",
city: "Philadelphia",
coordinate: CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665)
)
let camdenYards = makeStadium(
id: "camden-yards",
name: "Camden Yards",
city: "Baltimore",
coordinate: CLLocationCoordinate2D(latitude: 39.2838, longitude: -76.6215)
)
// Create games spread across 8-day window (4 teams * 2 = 8 days)
// For 4 teams, window = 8 days. Games must span at least 8 days.
let calendar = TestClock.calendar
let day1 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day3 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 3))!
let day5 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 5))!
let day8 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 8))!
let games = [
Game(id: "yankees-1", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "yankee-stadium",
dateTime: day1, sport: .mlb, season: "2026", isPlayoff: false),
Game(id: "redsox-1", homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "fenway-park",
dateTime: day3, sport: .mlb, season: "2026", isPlayoff: false),
Game(id: "phillies-1", homeTeamId: "phillies", awayTeamId: "opp", stadiumId: "citizens-bank",
dateTime: day5, sport: .mlb, season: "2026", isPlayoff: false),
Game(id: "orioles-1", homeTeamId: "orioles", awayTeamId: "opp", stadiumId: "camden-yards",
dateTime: day8, sport: .mlb, season: "2026", isPlayoff: false)
]
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox", "phillies", "orioles"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox"),
"phillies": makeTeam(id: "phillies", name: "Phillies"),
"orioles": makeTeam(id: "orioles", name: "Orioles")
],
stadiums: [
"yankee-stadium": yankeeStadium,
"fenway-park": fenwayPark,
"citizens-bank": citizensBank,
"camden-yards": camdenYards
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success with drivable east coast trip")
return
}
#expect(!options.isEmpty, "Should find routes for east coast trip")
// Verify each route visits all 4 cities
for option in options {
let citiesVisited = Set(option.stops.map { $0.city })
#expect(citiesVisited.count >= 4, "Each route should visit at least 4 cities (one per team)")
}
}
// MARK: - Helper Methods
private func makeStadium(
id: String,
name: String,
city: String,
coordinate: CLLocationCoordinate2D
) -> Stadium {
Stadium(
id: id,
name: name,
city: city,
state: "XX",
latitude: coordinate.latitude,
longitude: coordinate.longitude,
capacity: 40000,
sport: .mlb
)
}
private func makeTeam(id: String, name: String) -> Team {
Team(
id: id,
name: name,
abbreviation: String(name.prefix(3)).uppercased(),
sport: .mlb,
city: "Test City",
stadiumId: id
)
}
}