600 lines
21 KiB
Swift
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()
|
|
|
|
// 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
|
|
)
|
|
}
|
|
}
|