chore: remove scraper, add docs, add marketing-videos gitignore
- Remove Scripts/ directory (scraper no longer needed) - Add themed background documentation to CLAUDE.md - Add .gitignore for marketing-videos to prevent node_modules tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
971
SportsTimeTests/Planning/ScenarioEPlannerTests.swift
Normal file
971
SportsTimeTests/Planning/ScenarioEPlannerTests.swift
Normal file
@@ -0,0 +1,971 @@
|
||||
//
|
||||
// ScenarioEPlannerTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// TDD specification tests for ScenarioEPlanner (Team-First planning mode).
|
||||
//
|
||||
// Team-First mode allows users to select multiple teams and finds optimal
|
||||
// trip windows across the season where all selected teams have home games.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import CoreLocation
|
||||
@testable import SportsTime
|
||||
|
||||
@Suite("ScenarioEPlanner")
|
||||
struct ScenarioEPlannerTests {
|
||||
|
||||
// MARK: - Test Data
|
||||
|
||||
private let planner = ScenarioEPlanner()
|
||||
|
||||
// East Coast coordinates
|
||||
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
||||
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
|
||||
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
|
||||
|
||||
// Central coordinates
|
||||
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
|
||||
|
||||
// West Coast coordinates
|
||||
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
|
||||
|
||||
// MARK: - E1. Window Generator Tests
|
||||
|
||||
@Test("generateValidWindows: 2 teams with 4-day window finds valid windows")
|
||||
func generateValidWindows_2Teams4DayWindow_findsValidWindows() {
|
||||
// Setup: 2 teams (simpler case), each with home games in overlapping time periods
|
||||
// NYC and Boston are only ~4 hours apart, making routes feasible
|
||||
//
|
||||
// With 2 teams, window duration = 4 days.
|
||||
// The window algorithm checks: windowEnd <= latestGameDay + 1
|
||||
// So with games on day 1 and day 4: latestDay=4, windowEnd=5 <= day 5 (4+1) - valid!
|
||||
let calendar = Calendar.current
|
||||
let baseDate = calendar.startOfDay(for: Date())
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
|
||||
// Create games with evening times (7 PM) to give ample travel time
|
||||
// Day 1 evening: Yankees home game
|
||||
// Day 4 evening: Red Sox home game (spans 4 days, fits in 4-day window)
|
||||
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
|
||||
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
|
||||
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: day1Evening
|
||||
)
|
||||
let redsoxGame = makeGame(
|
||||
id: "redsox-home",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: day4Evening
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame, redsoxGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Should succeed - NYC to Boston is easily drivable
|
||||
guard case .success(let options) = result else {
|
||||
Issue.record("Expected success with overlapping home games for nearby cities")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(!options.isEmpty, "Should find valid windows when all teams have overlapping home games")
|
||||
}
|
||||
|
||||
@Test("generateValidWindows: window with only 2 of 3 teams excluded")
|
||||
func generateValidWindows_windowMissingTeam_excluded() {
|
||||
// Setup: 3 teams selected, but games are spread so no single window covers all 3
|
||||
let baseDate = Date()
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
|
||||
|
||||
// Yankees game on day 1
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
||||
)
|
||||
// Red Sox game on day 2
|
||||
let redsoxGame = makeGame(
|
||||
id: "redsox-home",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 2)
|
||||
)
|
||||
// Phillies game on day 20 (way outside any 6-day window with the others)
|
||||
let philliesGame = makeGame(
|
||||
id: "phillies-home",
|
||||
homeTeamId: "phillies",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "philly",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 20)
|
||||
)
|
||||
|
||||
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: [yankeesGame, redsoxGame, philliesGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox"),
|
||||
"phillies": makeTeam(id: "phillies", name: "Phillies")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .failure(let failure) = result else {
|
||||
Issue.record("Expected failure when no valid window covers all teams")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(failure.reason == .noValidRoutes, "Should return noValidRoutes when no window covers all teams")
|
||||
}
|
||||
|
||||
@Test("generateValidWindows: empty season returns empty")
|
||||
func generateValidWindows_emptySeason_returnsEmpty() {
|
||||
// Setup: No games available
|
||||
let baseDate = Date()
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [], // No games
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: [:]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .failure(let failure) = result else {
|
||||
Issue.record("Expected failure with no games")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(failure.reason == .noGamesInRange, "Should return noGamesInRange when no home games exist")
|
||||
}
|
||||
|
||||
@Test("generateValidWindows: sampling works when more than 50 windows")
|
||||
func generateValidWindows_manyWindows_samplesProperly() {
|
||||
// Setup: Create many overlapping games so there are >50 valid windows
|
||||
let calendar = Calendar.current
|
||||
let baseDate = calendar.startOfDay(for: Date())
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
|
||||
// Create games on alternating days - Yankees on even days, Red Sox on odd days
|
||||
// This ensures games don't conflict and routes are feasible
|
||||
// With 90 days of games, we get ~45 games per team with plenty of valid 4-day windows
|
||||
var games: [Game] = []
|
||||
for day in 0..<90 {
|
||||
let dayDate = baseDate.addingTimeInterval(Double(86400 * day))
|
||||
let eveningTime = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: dayDate)!
|
||||
|
||||
if day % 2 == 0 {
|
||||
// Yankees home games on even days
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-\(day)",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: eveningTime
|
||||
)
|
||||
games.append(yankeesGame)
|
||||
} else {
|
||||
// Red Sox home games on odd days
|
||||
let redsoxGame = makeGame(
|
||||
id: "redsox-\(day)",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: eveningTime
|
||||
)
|
||||
games.append(redsoxGame)
|
||||
}
|
||||
}
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 90),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: games,
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Should succeed and return results (sampling internal behavior)
|
||||
guard case .success(let options) = result else {
|
||||
Issue.record("Expected success with many valid windows")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(!options.isEmpty, "Should return options even with many windows (sampling applies internally)")
|
||||
#expect(options.count <= 10, "Should return at most 10 results")
|
||||
}
|
||||
|
||||
// MARK: - E2. ScenarioEPlanner Unit Tests
|
||||
|
||||
@Test("plan: returns PlanningResult with routes")
|
||||
func plan_validRequest_returnsPlanningResultWithRoutes() {
|
||||
let calendar = Calendar.current
|
||||
let baseDate = calendar.startOfDay(for: Date())
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
|
||||
// Use evening game times to allow travel during the day
|
||||
// Games must span 4 days for 2-team window (day 1 to day 4)
|
||||
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
|
||||
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
|
||||
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: day1Evening
|
||||
)
|
||||
let redsoxGame = makeGame(
|
||||
id: "redsox-home",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: day4Evening
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame, redsoxGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .success(let options) = result else {
|
||||
Issue.record("Expected success with valid team-first request")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(!options.isEmpty, "Should return route options")
|
||||
#expect(options.first?.stops.count ?? 0 > 0, "Each option should have stops")
|
||||
}
|
||||
|
||||
@Test("plan: all routes include all selected teams")
|
||||
func plan_allRoutesIncludeAllSelectedTeams() {
|
||||
// Use just 2 teams for a simpler, more reliable test
|
||||
let calendar = Calendar.current
|
||||
let baseDate = calendar.startOfDay(for: Date())
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
|
||||
// Games within a 4-day window (2 teams * 2) with evening times
|
||||
// Games must span 4 days for the window to be valid
|
||||
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
|
||||
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
|
||||
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: day1Evening
|
||||
)
|
||||
let redsoxGame = makeGame(
|
||||
id: "redsox-home",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: day4Evening
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame, redsoxGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .success(let options) = result else {
|
||||
Issue.record("Expected success with valid team-first request")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify each route includes games from all 2 teams
|
||||
for option in options {
|
||||
let allGameIds = Set(option.stops.flatMap { $0.games })
|
||||
|
||||
// Check that at least one home game per team is included
|
||||
let hasYankeesGame = allGameIds.contains("yankees-home")
|
||||
let hasRedsoxGame = allGameIds.contains("redsox-home")
|
||||
|
||||
#expect(hasYankeesGame, "Every route must include Yankees home game")
|
||||
#expect(hasRedsoxGame, "Every route must include Red Sox home game")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("plan: routes sorted by duration ascending")
|
||||
func plan_routesSortedByDurationAscending() {
|
||||
let baseDate = Date()
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
|
||||
// Create multiple windows with different durations
|
||||
// Window 1: Games on day 1 and 2 (shorter trip)
|
||||
// Window 2: Games on day 10 and 14 (longer trip within window)
|
||||
let yankeesGame1 = makeGame(
|
||||
id: "yankees-1",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
||||
)
|
||||
let redsoxGame1 = makeGame(
|
||||
id: "redsox-1",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 2)
|
||||
)
|
||||
let yankeesGame2 = makeGame(
|
||||
id: "yankees-2",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 10)
|
||||
)
|
||||
let redsoxGame2 = makeGame(
|
||||
id: "redsox-2",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 12)
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame1, redsoxGame1, yankeesGame2, redsoxGame2],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .success(let options) = result else {
|
||||
Issue.record("Expected success")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ranking is assigned correctly
|
||||
for (index, option) in options.enumerated() {
|
||||
#expect(option.rank == index + 1, "Routes should be ranked 1, 2, 3...")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("plan: respects max driving time constraint")
|
||||
func plan_respectsMaxDrivingTimeConstraint() {
|
||||
let baseDate = Date()
|
||||
|
||||
// NYC and LA are ~40 hours apart by car
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
|
||||
|
||||
// Games on consecutive days - impossible to drive between
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
||||
)
|
||||
let dodgersGame = makeGame(
|
||||
id: "dodgers-home",
|
||||
homeTeamId: "dodgers",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "la",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 2) // Next day - impossible
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 1, // Single driver, 8 hours max
|
||||
selectedTeamIds: ["yankees", "dodgers"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame, dodgersGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"dodgers": makeTeam(id: "dodgers", name: "Dodgers")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "la": laStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Should fail because driving constraint cannot be met
|
||||
guard case .failure(let failure) = result else {
|
||||
Issue.record("Expected failure when driving constraint cannot be met")
|
||||
return
|
||||
}
|
||||
|
||||
// Could be noValidRoutes or constraintsUnsatisfiable
|
||||
let validFailures: [PlanningFailure.FailureReason] = [.noValidRoutes, .constraintsUnsatisfiable]
|
||||
#expect(validFailures.contains { $0 == failure.reason }, "Should fail due to route constraints")
|
||||
}
|
||||
|
||||
// MARK: - E4. Edge Case Tests
|
||||
|
||||
@Test("plan: teams with no overlapping games returns graceful error")
|
||||
func plan_noOverlappingGames_returnsGracefulError() {
|
||||
let baseDate = Date()
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
|
||||
// Yankees plays in January, Red Sox plays in July - no overlap
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate // Day 0
|
||||
)
|
||||
let redsoxGame = makeGame(
|
||||
id: "redsox-home",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 180) // 6 months later
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 365),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame, redsoxGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .failure(let failure) = result else {
|
||||
Issue.record("Expected failure when teams have no overlapping game windows")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(failure.reason == .noValidRoutes, "Should return noValidRoutes for non-overlapping schedules")
|
||||
}
|
||||
|
||||
@Test("plan: single team selected returns validation error")
|
||||
func plan_singleTeamSelected_returnsValidationError() {
|
||||
let baseDate = Date()
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees"] // Only 1 team
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame],
|
||||
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees")],
|
||||
stadiums: ["nyc": nycStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .failure(let failure) = result else {
|
||||
Issue.record("Expected failure when only 1 team selected")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(failure.reason == .missingTeamSelection, "Should return missingTeamSelection for single team")
|
||||
}
|
||||
|
||||
@Test("plan: no teams selected returns validation error")
|
||||
func plan_noTeamsSelected_returnsValidationError() {
|
||||
let baseDate = Date()
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: [] // No teams
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [],
|
||||
teams: [:],
|
||||
stadiums: [:]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .failure(let failure) = result else {
|
||||
Issue.record("Expected failure when no teams selected")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(failure.reason == .missingTeamSelection, "Should return missingTeamSelection for no teams")
|
||||
}
|
||||
|
||||
@Test("plan: teams in same city treated as separate stops")
|
||||
func plan_teamsInSameCity_treatedAsSeparateStops() {
|
||||
// Setup: Yankees and Mets both play in NYC but at different stadiums
|
||||
let calendar = Calendar.current
|
||||
let baseDate = calendar.startOfDay(for: Date())
|
||||
|
||||
let yankeeStadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262)
|
||||
let citiFieldCoord = CLLocationCoordinate2D(latitude: 40.7571, longitude: -73.8458)
|
||||
|
||||
let yankeeStadium = makeStadium(id: "yankee-stadium", city: "New York", coordinate: yankeeStadiumCoord)
|
||||
let citiField = makeStadium(id: "citi-field", city: "New York", coordinate: citiFieldCoord)
|
||||
|
||||
// Games on different days in the same city with evening times
|
||||
// Games must span 4 days for the 2-team window to be valid
|
||||
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
|
||||
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
|
||||
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "yankee-stadium",
|
||||
dateTime: day1Evening
|
||||
)
|
||||
let metsGame = makeGame(
|
||||
id: "mets-home",
|
||||
homeTeamId: "mets",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "citi-field",
|
||||
dateTime: day4Evening
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "mets"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame, metsGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"mets": makeTeam(id: "mets", name: "Mets")
|
||||
],
|
||||
stadiums: ["yankee-stadium": yankeeStadium, "citi-field": citiField]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .success(let options) = result else {
|
||||
Issue.record("Expected success with same-city teams")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(!options.isEmpty, "Should find routes for same-city teams")
|
||||
|
||||
// Verify both games are included in routes
|
||||
for option in options {
|
||||
let allGameIds = Set(option.stops.flatMap { $0.games })
|
||||
#expect(allGameIds.contains("yankees-home"), "Should include Yankees game")
|
||||
#expect(allGameIds.contains("mets-home"), "Should include Mets game")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("plan: team with no home games returns error")
|
||||
func plan_teamWithNoHomeGames_returnsError() {
|
||||
let baseDate = Date()
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
|
||||
// Only Yankees have a home game, Red Sox have no home games
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "redsox", // Red Sox are away
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"] // Red Sox selected but no home games
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
guard case .failure(let failure) = result else {
|
||||
Issue.record("Expected failure when selected team has no home games")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(failure.reason == .noGamesInRange, "Should return noGamesInRange when team has no home games")
|
||||
}
|
||||
|
||||
// MARK: - Invariant Tests
|
||||
|
||||
@Test("Invariant: window duration equals teams count times 2")
|
||||
func invariant_windowDurationEqualsTeamsTimestwo() {
|
||||
// Test that teamFirstMaxDays is calculated correctly
|
||||
var prefs = TripPreferences()
|
||||
prefs.selectedTeamIds = ["team1", "team2", "team3"]
|
||||
|
||||
#expect(prefs.teamFirstMaxDays == 6, "3 teams should result in 6-day window")
|
||||
|
||||
prefs.selectedTeamIds = ["team1", "team2"]
|
||||
#expect(prefs.teamFirstMaxDays == 4, "2 teams should result in 4-day window")
|
||||
|
||||
prefs.selectedTeamIds = ["team1", "team2", "team3", "team4", "team5"]
|
||||
#expect(prefs.teamFirstMaxDays == 10, "5 teams should result in 10-day window")
|
||||
}
|
||||
|
||||
@Test("Invariant: maximum 10 results returned")
|
||||
func invariant_maximum10ResultsReturned() {
|
||||
let baseDate = Date()
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
|
||||
// Create many games to generate many possible routes
|
||||
var games: [Game] = []
|
||||
for day in 0..<60 {
|
||||
games.append(makeGame(
|
||||
id: "yankees-\(day)",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate.addingTimeInterval(Double(86400 * day))
|
||||
))
|
||||
games.append(makeGame(
|
||||
id: "redsox-\(day)",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: baseDate.addingTimeInterval(Double(86400 * day))
|
||||
))
|
||||
}
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 60),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: games,
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
if case .success(let options) = result {
|
||||
#expect(options.count <= 10, "Should return at most 10 results")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Invariant: all routes contain home games from all selected teams")
|
||||
func invariant_allRoutesContainAllSelectedTeams() {
|
||||
let baseDate = Date()
|
||||
|
||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||
|
||||
let yankeesGame = makeGame(
|
||||
id: "yankees-home",
|
||||
homeTeamId: "yankees",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "nyc",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
||||
)
|
||||
let redsoxGame = makeGame(
|
||||
id: "redsox-home",
|
||||
homeTeamId: "redsox",
|
||||
awayTeamId: "opponent",
|
||||
stadiumId: "boston",
|
||||
dateTime: baseDate.addingTimeInterval(86400 * 2)
|
||||
)
|
||||
|
||||
let prefs = TripPreferences(
|
||||
planningMode: .teamFirst,
|
||||
sports: [.mlb],
|
||||
startDate: baseDate,
|
||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||
leisureLevel: .moderate,
|
||||
lodgingType: .hotel,
|
||||
numberOfDrivers: 2,
|
||||
selectedTeamIds: ["yankees", "redsox"]
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: prefs,
|
||||
availableGames: [yankeesGame, redsoxGame],
|
||||
teams: [
|
||||
"yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||
"redsox": makeTeam(id: "redsox", name: "Red Sox")
|
||||
],
|
||||
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||
)
|
||||
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
if case .success(let options) = result {
|
||||
for option in options {
|
||||
let allGameIds = Set(option.stops.flatMap { $0.games })
|
||||
|
||||
// At minimum, should have one game per selected team
|
||||
let hasYankeesGame = allGameIds.contains { gameId in
|
||||
// Check if any game in this route is a Yankees home game
|
||||
request.availableGames.first { $0.id == gameId }?.homeTeamId == "yankees"
|
||||
}
|
||||
let hasRedsoxGame = allGameIds.contains { gameId in
|
||||
request.availableGames.first { $0.id == gameId }?.homeTeamId == "redsox"
|
||||
}
|
||||
|
||||
#expect(hasYankeesGame, "Every route must include a Yankees home game")
|
||||
#expect(hasRedsoxGame, "Every route must include a Red Sox home game")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func makeStadium(
|
||||
id: String,
|
||||
city: String,
|
||||
coordinate: CLLocationCoordinate2D
|
||||
) -> Stadium {
|
||||
Stadium(
|
||||
id: id,
|
||||
name: "\(city) Stadium",
|
||||
city: city,
|
||||
state: "XX",
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
capacity: 40000,
|
||||
sport: .mlb
|
||||
)
|
||||
}
|
||||
|
||||
private func makeGame(
|
||||
id: String,
|
||||
homeTeamId: String,
|
||||
awayTeamId: String,
|
||||
stadiumId: String,
|
||||
dateTime: Date
|
||||
) -> Game {
|
||||
Game(
|
||||
id: id,
|
||||
homeTeamId: homeTeamId,
|
||||
awayTeamId: awayTeamId,
|
||||
stadiumId: stadiumId,
|
||||
dateTime: dateTime,
|
||||
sport: .mlb,
|
||||
season: "2026",
|
||||
isPlayoff: false
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
primaryColor: "#000000",
|
||||
secondaryColor: "#FFFFFF"
|
||||
)
|
||||
}
|
||||
}
|
||||
599
SportsTimeTests/Planning/TeamFirstIntegrationTests.swift
Normal file
599
SportsTimeTests/Planning/TeamFirstIntegrationTests.swift
Normal file
@@ -0,0 +1,599 @@
|
||||
//
|
||||
// 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 = Date()
|
||||
|
||||
// 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 = Calendar.current
|
||||
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 = Date()
|
||||
|
||||
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 = Date()
|
||||
|
||||
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 = Calendar.current
|
||||
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 = Calendar.current
|
||||
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 = Date()
|
||||
|
||||
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 = Date()
|
||||
|
||||
// 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 = Date()
|
||||
|
||||
// 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 = Calendar.current
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user