This refactor fixes the achievement system by using stable canonical string IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures stadium mappings for achievements are consistent across app launches and CloudKit sync operations. Changes: - Stadium, Team, Game: id property changed from UUID to String - Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums - CKModels: removed UUID parsing, use canonical IDs directly - AchievementEngine: now matches against canonical stadium IDs - All test files updated to use String IDs instead of UUID() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
734 lines
30 KiB
Swift
734 lines
30 KiB
Swift
//
|
||
// TripPlanningEngineTests.swift
|
||
// SportsTimeTests
|
||
//
|
||
// Phase 7: TripPlanningEngine Integration Tests
|
||
// Main orchestrator — tests all scenarios together.
|
||
//
|
||
|
||
import Testing
|
||
import CoreLocation
|
||
@testable import SportsTime
|
||
|
||
@Suite("TripPlanningEngine Tests", .serialized)
|
||
struct TripPlanningEngineTests {
|
||
|
||
// MARK: - Test Fixtures
|
||
|
||
private let calendar = Calendar.current
|
||
|
||
/// Creates a fresh engine for each test to avoid parallel execution issues
|
||
private func makeEngine() -> TripPlanningEngine {
|
||
TripPlanningEngine()
|
||
}
|
||
|
||
/// Creates a date with specific year/month/day/hour
|
||
private func makeDate(year: Int = 2026, month: Int = 6, day: Int, hour: Int = 19) -> Date {
|
||
var components = DateComponents()
|
||
components.year = year
|
||
components.month = month
|
||
components.day = day
|
||
components.hour = hour
|
||
components.minute = 0
|
||
return calendar.date(from: components)!
|
||
}
|
||
|
||
/// Creates a stadium at a known location
|
||
private func makeStadium(
|
||
id: String = "stadium_test_\(UUID().uuidString)",
|
||
city: String,
|
||
lat: Double,
|
||
lon: Double,
|
||
sport: Sport = .mlb
|
||
) -> Stadium {
|
||
Stadium(
|
||
id: id,
|
||
name: "\(city) Stadium",
|
||
city: city,
|
||
state: "ST",
|
||
latitude: lat,
|
||
longitude: lon,
|
||
capacity: 40000,
|
||
sport: sport
|
||
)
|
||
}
|
||
|
||
/// Creates a game at a stadium
|
||
private func makeGame(
|
||
id: String = "game_test_\(UUID().uuidString)",
|
||
stadiumId: String,
|
||
homeTeamId: String = "team_test_\(UUID().uuidString)",
|
||
awayTeamId: String = "team_test_\(UUID().uuidString)",
|
||
dateTime: Date,
|
||
sport: Sport = .mlb
|
||
) -> Game {
|
||
Game(
|
||
id: id,
|
||
homeTeamId: homeTeamId,
|
||
awayTeamId: awayTeamId,
|
||
stadiumId: stadiumId,
|
||
dateTime: dateTime,
|
||
sport: sport,
|
||
season: "2026"
|
||
)
|
||
}
|
||
|
||
/// Creates a PlanningRequest for Scenario A (date range only)
|
||
private func makeScenarioARequest(
|
||
startDate: Date,
|
||
endDate: Date,
|
||
games: [Game],
|
||
stadiums: [String: Stadium],
|
||
numberOfDrivers: Int = 1,
|
||
maxDrivingHoursPerDriver: Double = 8.0,
|
||
allowRepeatCities: Bool = true
|
||
) -> PlanningRequest {
|
||
let preferences = TripPreferences(
|
||
planningMode: .dateRange,
|
||
sports: [.mlb],
|
||
startDate: startDate,
|
||
endDate: endDate,
|
||
leisureLevel: .moderate,
|
||
numberOfDrivers: numberOfDrivers,
|
||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||
allowRepeatCities: allowRepeatCities
|
||
)
|
||
|
||
return PlanningRequest(
|
||
preferences: preferences,
|
||
availableGames: games,
|
||
teams: [:],
|
||
stadiums: stadiums
|
||
)
|
||
}
|
||
|
||
/// Creates a PlanningRequest for Scenario B (selected games)
|
||
private func makeScenarioBRequest(
|
||
mustSeeGameIds: Set<String>,
|
||
startDate: Date,
|
||
endDate: Date,
|
||
games: [Game],
|
||
stadiums: [String: Stadium],
|
||
numberOfDrivers: Int = 1,
|
||
maxDrivingHoursPerDriver: Double = 8.0,
|
||
allowRepeatCities: Bool = true
|
||
) -> PlanningRequest {
|
||
let preferences = TripPreferences(
|
||
planningMode: .gameFirst,
|
||
sports: [.mlb],
|
||
mustSeeGameIds: mustSeeGameIds,
|
||
startDate: startDate,
|
||
endDate: endDate,
|
||
leisureLevel: .moderate,
|
||
numberOfDrivers: numberOfDrivers,
|
||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||
allowRepeatCities: allowRepeatCities
|
||
)
|
||
|
||
return PlanningRequest(
|
||
preferences: preferences,
|
||
availableGames: games,
|
||
teams: [:],
|
||
stadiums: stadiums
|
||
)
|
||
}
|
||
|
||
/// Creates a PlanningRequest for Scenario C (start/end locations)
|
||
private func makeScenarioCRequest(
|
||
startLocation: LocationInput,
|
||
endLocation: LocationInput,
|
||
startDate: Date,
|
||
endDate: Date,
|
||
games: [Game],
|
||
stadiums: [String: Stadium],
|
||
numberOfDrivers: Int = 1,
|
||
maxDrivingHoursPerDriver: Double = 8.0
|
||
) -> PlanningRequest {
|
||
let preferences = TripPreferences(
|
||
planningMode: .locations,
|
||
startLocation: startLocation,
|
||
endLocation: endLocation,
|
||
sports: [.mlb],
|
||
startDate: startDate,
|
||
endDate: endDate,
|
||
leisureLevel: .moderate,
|
||
numberOfDrivers: numberOfDrivers,
|
||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver
|
||
)
|
||
|
||
return PlanningRequest(
|
||
preferences: preferences,
|
||
availableGames: games,
|
||
teams: [:],
|
||
stadiums: stadiums
|
||
)
|
||
}
|
||
|
||
// MARK: - 7A: Scenario Routing
|
||
|
||
@Test("7.1 - Engine delegates to Scenario A correctly")
|
||
func test_engine_ScenarioA_DelegatesCorrectly() {
|
||
// Setup: Date range only request (Scenario A)
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
||
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
||
|
||
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee]
|
||
|
||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
|
||
|
||
let request = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums
|
||
)
|
||
|
||
// Verify this is classified as Scenario A
|
||
let scenario = ScenarioPlannerFactory.classify(request)
|
||
#expect(scenario == .scenarioA, "Should be classified as Scenario A")
|
||
|
||
// Execute through engine
|
||
let result = makeEngine().planItineraries(request: request)
|
||
|
||
// Verify
|
||
#expect(result.isSuccess, "Engine should successfully delegate to Scenario A planner")
|
||
#expect(!result.options.isEmpty, "Should return itinerary options")
|
||
}
|
||
|
||
@Test("7.2 - Engine delegates to Scenario B correctly")
|
||
func test_engine_ScenarioB_DelegatesCorrectly() {
|
||
// Setup: Selected games request (Scenario B)
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
||
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
||
|
||
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee]
|
||
|
||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
|
||
|
||
// User selects specific games
|
||
let request = makeScenarioBRequest(
|
||
mustSeeGameIds: [game1.id, game2.id],
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums
|
||
)
|
||
|
||
// Verify this is classified as Scenario B
|
||
let scenario = ScenarioPlannerFactory.classify(request)
|
||
#expect(scenario == .scenarioB, "Should be classified as Scenario B when games are selected")
|
||
|
||
// Execute through engine
|
||
let result = makeEngine().planItineraries(request: request)
|
||
|
||
// Verify
|
||
#expect(result.isSuccess, "Engine should successfully delegate to Scenario B planner")
|
||
if result.isSuccess {
|
||
// All selected games should be in the routes
|
||
for option in result.options {
|
||
let gameIds = Set(option.stops.flatMap { $0.games })
|
||
#expect(gameIds.contains(game1.id), "Should contain first selected game")
|
||
#expect(gameIds.contains(game2.id), "Should contain second selected game")
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test("7.3 - Engine delegates to Scenario C correctly")
|
||
func test_engine_ScenarioC_DelegatesCorrectly() {
|
||
// Setup: Start/end locations request (Scenario C)
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let clevelandId = "stadium_cleveland_\(UUID().uuidString)"
|
||
let detroitId = "stadium_detroit_\(UUID().uuidString)"
|
||
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let cleveland = makeStadium(id: clevelandId, city: "Cleveland", lat: 41.4993, lon: -81.6944)
|
||
let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458)
|
||
|
||
let stadiums = [chicagoId: chicago, clevelandId: cleveland, detroitId: detroit]
|
||
|
||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 7, hour: 19))
|
||
let game3 = makeGame(stadiumId: clevelandId, dateTime: makeDate(day: 9, hour: 19))
|
||
|
||
let startLocation = LocationInput(
|
||
name: "Chicago",
|
||
coordinate: CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
|
||
)
|
||
let endLocation = LocationInput(
|
||
name: "Cleveland",
|
||
coordinate: CLLocationCoordinate2D(latitude: 41.4993, longitude: -81.6944)
|
||
)
|
||
|
||
let request = makeScenarioCRequest(
|
||
startLocation: startLocation,
|
||
endLocation: endLocation,
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 12, hour: 23),
|
||
games: [game1, game2, game3],
|
||
stadiums: stadiums
|
||
)
|
||
|
||
// Verify this is classified as Scenario C
|
||
let scenario = ScenarioPlannerFactory.classify(request)
|
||
#expect(scenario == .scenarioC, "Should be classified as Scenario C when locations are specified")
|
||
|
||
// Execute through engine
|
||
let result = makeEngine().planItineraries(request: request)
|
||
|
||
// Scenario C may succeed or fail depending on directional filtering
|
||
// The key test is that it correctly identifies and delegates to Scenario C
|
||
if result.isSuccess {
|
||
#expect(!result.options.isEmpty, "If success, should have options")
|
||
}
|
||
// Failure is also valid (e.g., no directional routes found)
|
||
}
|
||
|
||
@Test("7.4 - Scenarios are mutually exclusive")
|
||
func test_engine_ScenariosAreMutuallyExclusive() {
|
||
// Setup: Create requests that could theoretically match multiple scenarios
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let clevelandId = "stadium_cleveland_\(UUID().uuidString)"
|
||
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let cleveland = makeStadium(id: clevelandId, city: "Cleveland", lat: 41.4993, lon: -81.6944)
|
||
|
||
let stadiums = [chicagoId: chicago, clevelandId: cleveland]
|
||
|
||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: clevelandId, dateTime: makeDate(day: 7, hour: 19))
|
||
|
||
// Request with BOTH selected games AND start/end locations
|
||
// According to priority: Scenario B (selected games) takes precedence
|
||
let preferences = TripPreferences(
|
||
planningMode: .locations,
|
||
startLocation: LocationInput(
|
||
name: "Chicago",
|
||
coordinate: CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
|
||
),
|
||
endLocation: LocationInput(
|
||
name: "Cleveland",
|
||
coordinate: CLLocationCoordinate2D(latitude: 41.4993, longitude: -81.6944)
|
||
),
|
||
sports: [.mlb],
|
||
mustSeeGameIds: [game1.id], // Has selected games!
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23)
|
||
)
|
||
|
||
let request = PlanningRequest(
|
||
preferences: preferences,
|
||
availableGames: [game1, game2],
|
||
teams: [:],
|
||
stadiums: stadiums
|
||
)
|
||
|
||
// Verify: Selected games (Scenario B) takes precedence over locations (Scenario C)
|
||
let scenario = ScenarioPlannerFactory.classify(request)
|
||
#expect(scenario == .scenarioB, "Scenario B should take precedence when games are selected")
|
||
|
||
// Scenario A should only be selected when no games selected AND no locations
|
||
let scenarioARequest = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums
|
||
)
|
||
let scenarioA = ScenarioPlannerFactory.classify(scenarioARequest)
|
||
#expect(scenarioA == .scenarioA, "Scenario A is default when no games/locations specified")
|
||
}
|
||
|
||
// MARK: - 7B: Result Structure
|
||
|
||
@Test("7.5 - Result contains travel segments")
|
||
func test_engine_Result_ContainsTravelSegments() {
|
||
// Setup: Multi-city trip that requires travel
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
||
let detroitId = "stadium_detroit_\(UUID().uuidString)"
|
||
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
||
let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458)
|
||
|
||
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee, detroitId: detroit]
|
||
|
||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
|
||
let game3 = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 9, hour: 19))
|
||
|
||
let request = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23),
|
||
games: [game1, game2, game3],
|
||
stadiums: stadiums
|
||
)
|
||
|
||
// Execute
|
||
let result = makeEngine().planItineraries(request: request)
|
||
|
||
// Verify
|
||
#expect(result.isSuccess, "Should succeed with valid multi-city request")
|
||
|
||
for option in result.options {
|
||
if option.stops.count > 1 {
|
||
// Travel segments should exist between stops
|
||
// INVARIANT: travelSegments.count == stops.count - 1
|
||
#expect(option.travelSegments.count == option.stops.count - 1,
|
||
"Should have N-1 travel segments for N stops")
|
||
|
||
// Each segment should have valid data
|
||
for segment in option.travelSegments {
|
||
#expect(segment.distanceMeters > 0, "Segment should have positive distance")
|
||
#expect(segment.durationSeconds > 0, "Segment should have positive duration")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test("7.6 - Result contains itinerary days")
|
||
func test_engine_Result_ContainsItineraryDays() {
|
||
// Setup: Multi-day trip
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
||
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
||
|
||
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee]
|
||
|
||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 8, hour: 19))
|
||
|
||
let request = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums
|
||
)
|
||
|
||
// Execute
|
||
let result = makeEngine().planItineraries(request: request)
|
||
|
||
// Verify
|
||
#expect(result.isSuccess, "Should succeed with valid request")
|
||
|
||
for option in result.options {
|
||
// Each stop represents a day/location
|
||
#expect(!option.stops.isEmpty, "Should have at least one stop")
|
||
|
||
// Stops should have arrival/departure dates
|
||
for stop in option.stops {
|
||
#expect(stop.arrivalDate <= stop.departureDate,
|
||
"Arrival should be before or equal to departure")
|
||
}
|
||
|
||
// Can generate timeline
|
||
let timeline = option.generateTimeline()
|
||
#expect(!timeline.isEmpty, "Should generate non-empty timeline")
|
||
|
||
// Timeline should have stops
|
||
let stopItems = timeline.filter { $0.isStop }
|
||
#expect(stopItems.count == option.stops.count,
|
||
"Timeline should contain all stops")
|
||
}
|
||
}
|
||
|
||
@Test("7.7 - Result includes warnings when applicable")
|
||
func test_engine_Result_IncludesWarnings_WhenApplicable() {
|
||
// Setup: Request that would normally violate repeat cities
|
||
// but allowRepeatCities=true so it should succeed without warnings
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
|
||
let stadiums = [chicagoId: chicago]
|
||
|
||
// Two games in the same city on different days
|
||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 7, hour: 19))
|
||
|
||
// Test with allowRepeatCities = true (should succeed)
|
||
let allowRequest = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums,
|
||
allowRepeatCities: true
|
||
)
|
||
|
||
let allowResult = makeEngine().planItineraries(request: allowRequest)
|
||
#expect(allowResult.isSuccess, "Should succeed when repeat cities allowed")
|
||
|
||
// Test with allowRepeatCities = false (may fail with repeat city violation)
|
||
let disallowRequest = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums,
|
||
allowRepeatCities: false
|
||
)
|
||
|
||
let disallowResult = makeEngine().planItineraries(request: disallowRequest)
|
||
|
||
// When repeat cities not allowed and only option is same city,
|
||
// should fail with repeatCityViolation
|
||
if !disallowResult.isSuccess {
|
||
if case .repeatCityViolation = disallowResult.failure?.reason {
|
||
// Expected - verify the violating cities are listed
|
||
if case .repeatCityViolation(let cities) = disallowResult.failure?.reason {
|
||
#expect(cities.contains("Chicago"),
|
||
"Should identify Chicago as the repeat city")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 7C: Constraint Application
|
||
|
||
@Test("7.8 - Number of drivers affects max daily driving")
|
||
func test_engine_NumberOfDrivers_AffectsMaxDailyDriving() {
|
||
// Setup: Long distance trip that requires significant driving
|
||
// NYC to Chicago is ~790 miles (~13 hours of driving)
|
||
let nycId = "stadium_nyc_\(UUID().uuidString)"
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
|
||
let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352)
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
|
||
let stadiums = [nycId: nyc, chicagoId: chicago]
|
||
|
||
// Games on consecutive days - tight schedule
|
||
let game1 = makeGame(stadiumId: nycId, dateTime: makeDate(day: 5, hour: 14))
|
||
let game2 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 6, hour: 20))
|
||
|
||
// With 1 driver (8 hours/day max), this should be very difficult
|
||
let singleDriverRequest = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 8, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums,
|
||
numberOfDrivers: 1,
|
||
maxDrivingHoursPerDriver: 8.0
|
||
)
|
||
|
||
let singleDriverResult = makeEngine().planItineraries(request: singleDriverRequest)
|
||
|
||
// With 2 drivers (16 hours/day max), this should be more feasible
|
||
let twoDriverRequest = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 8, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums,
|
||
numberOfDrivers: 2,
|
||
maxDrivingHoursPerDriver: 8.0
|
||
)
|
||
|
||
let twoDriverResult = makeEngine().planItineraries(request: twoDriverRequest)
|
||
|
||
// The driving constraints are calculated as: numberOfDrivers * maxHoursPerDriver
|
||
let singleDriverConstraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||
let twoDriverConstraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
|
||
|
||
#expect(singleDriverConstraints.maxDailyDrivingHours == 8.0,
|
||
"Single driver should have 8 hours max daily")
|
||
#expect(twoDriverConstraints.maxDailyDrivingHours == 16.0,
|
||
"Two drivers should have 16 hours max daily")
|
||
|
||
// Two drivers should have more routing flexibility
|
||
// (may or may not produce different results depending on route feasibility)
|
||
if singleDriverResult.isSuccess && twoDriverResult.isSuccess {
|
||
// Both succeeded - that's fine
|
||
} else if !singleDriverResult.isSuccess && twoDriverResult.isSuccess {
|
||
// Two drivers enabled a route that single driver couldn't - expected
|
||
}
|
||
// Either outcome demonstrates the constraint is being applied
|
||
}
|
||
|
||
@Test("7.9 - Max driving per day is respected")
|
||
func test_engine_MaxDrivingPerDay_Respected() {
|
||
// Test that DrivingConstraints correctly calculates max daily driving hours
|
||
// based on number of drivers and hours per driver
|
||
|
||
// Single driver: 1 × 8 = 8 hours max daily
|
||
let singleDriver = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||
#expect(singleDriver.maxDailyDrivingHours == 8.0,
|
||
"Single driver should have 8 hours max daily")
|
||
|
||
// Two drivers: 2 × 8 = 16 hours max daily
|
||
let twoDrivers = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
|
||
#expect(twoDrivers.maxDailyDrivingHours == 16.0,
|
||
"Two drivers should have 16 hours max daily")
|
||
|
||
// Three drivers: 3 × 8 = 24 hours max daily
|
||
let threeDrivers = DrivingConstraints(numberOfDrivers: 3, maxHoursPerDriverPerDay: 8.0)
|
||
#expect(threeDrivers.maxDailyDrivingHours == 24.0,
|
||
"Three drivers should have 24 hours max daily")
|
||
|
||
// Custom hours: 2 × 6 = 12 hours max daily
|
||
let customHours = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 6.0)
|
||
#expect(customHours.maxDailyDrivingHours == 12.0,
|
||
"Two drivers with 6 hours each should have 12 hours max daily")
|
||
|
||
// Verify default constraints
|
||
let defaultConstraints = DrivingConstraints.default
|
||
#expect(defaultConstraints.numberOfDrivers == 1,
|
||
"Default should have 1 driver")
|
||
#expect(defaultConstraints.maxHoursPerDriverPerDay == 8.0,
|
||
"Default should have 8 hours per driver")
|
||
#expect(defaultConstraints.maxDailyDrivingHours == 8.0,
|
||
"Default max daily should be 8 hours")
|
||
|
||
// Verify constraints from preferences are propagated correctly
|
||
// (The actual engine planning is tested in other tests)
|
||
}
|
||
|
||
@Test("7.10 - AllowRepeatCities is propagated to DAG")
|
||
func test_engine_AllowRepeatCities_PropagatedToDAG() {
|
||
// Setup: Games that would require visiting the same city twice
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
||
|
||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
||
|
||
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee]
|
||
|
||
// Chicago → Milwaukee → Chicago pattern
|
||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
|
||
let game3 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 9, hour: 19))
|
||
|
||
// Test with allowRepeatCities = true
|
||
let allowRequest = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 12, hour: 23),
|
||
games: [game1, game2, game3],
|
||
stadiums: stadiums,
|
||
allowRepeatCities: true
|
||
)
|
||
|
||
let allowResult = makeEngine().planItineraries(request: allowRequest)
|
||
|
||
// Test with allowRepeatCities = false
|
||
let disallowRequest = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 12, hour: 23),
|
||
games: [game1, game2, game3],
|
||
stadiums: stadiums,
|
||
allowRepeatCities: false
|
||
)
|
||
|
||
let disallowResult = makeEngine().planItineraries(request: disallowRequest)
|
||
|
||
// With allowRepeatCities = true, should be able to include all 3 games
|
||
if allowResult.isSuccess {
|
||
let hasThreeGameOption = allowResult.options.contains { $0.totalGames == 3 }
|
||
// May or may not have 3-game option depending on route feasibility
|
||
// but the option should be available
|
||
}
|
||
|
||
// With allowRepeatCities = false:
|
||
// - Either routes with repeat cities are filtered out
|
||
// - Or if no other option, may fail with repeatCityViolation
|
||
if disallowResult.isSuccess {
|
||
// Verify no routes have the same city appearing multiple times
|
||
for option in disallowResult.options {
|
||
let cities = option.stops.map { $0.city }
|
||
let uniqueCities = Set(cities)
|
||
// Note: Same city can appear if it's the start/end points
|
||
// The constraint is about not revisiting cities mid-trip
|
||
}
|
||
} else if case .repeatCityViolation = disallowResult.failure?.reason {
|
||
// Expected when the only valid routes require repeat cities
|
||
}
|
||
}
|
||
|
||
// MARK: - 7D: Error Handling
|
||
|
||
@Test("7.11 - Impossible constraints returns no result or excludes unreachable anchors")
|
||
func test_engine_ImpossibleConstraints_ReturnsNoResult() {
|
||
// Setup: Create an impossible constraint scenario
|
||
// Games at the same time on same day in cities far apart (can't make both)
|
||
let nycId = "stadium_nyc_\(UUID().uuidString)"
|
||
let laId = "stadium_la_\(UUID().uuidString)"
|
||
|
||
// NYC to LA is ~2,800 miles - impossible to drive same day
|
||
let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352)
|
||
let la = makeStadium(id: laId, city: "Los Angeles", lat: 34.0522, lon: -118.2437)
|
||
|
||
let stadiums = [nycId: nyc, laId: la]
|
||
|
||
// Games at exact same time on same day - impossible to attend both
|
||
let game1 = makeGame(stadiumId: nycId, dateTime: makeDate(day: 5, hour: 19))
|
||
let game2 = makeGame(stadiumId: laId, dateTime: makeDate(day: 5, hour: 19))
|
||
|
||
// Request that requires BOTH games (Scenario B with anchors)
|
||
let request = makeScenarioBRequest(
|
||
mustSeeGameIds: [game1.id, game2.id],
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 6, hour: 23),
|
||
games: [game1, game2],
|
||
stadiums: stadiums
|
||
)
|
||
|
||
// Execute
|
||
let result = makeEngine().planItineraries(request: request)
|
||
|
||
// Two valid behaviors for impossible constraints:
|
||
// 1. Fail with an error (constraintsUnsatisfiable or noValidRoutes)
|
||
// 2. Succeed but no route contains BOTH anchor games
|
||
//
|
||
// The key assertion: no valid route can contain BOTH games
|
||
if result.isSuccess {
|
||
// If success, verify no route contains both games
|
||
for option in result.options {
|
||
let gameIds = Set(option.stops.flatMap { $0.games })
|
||
let hasBoth = gameIds.contains(game1.id) && gameIds.contains(game2.id)
|
||
#expect(!hasBoth, "No route should contain both games at the same time in distant cities")
|
||
}
|
||
} else {
|
||
// Failure is the expected primary behavior
|
||
if let failure = result.failure {
|
||
// Valid failure reasons
|
||
let validReasons: [PlanningFailure.FailureReason] = [
|
||
.constraintsUnsatisfiable,
|
||
.noValidRoutes
|
||
]
|
||
let reasonIsValid = validReasons.contains { $0 == failure.reason }
|
||
#expect(reasonIsValid, "Should have appropriate failure reason: \(failure.reason)")
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test("7.12 - Empty input returns error")
|
||
func test_engine_EmptyInput_ThrowsError() {
|
||
// Setup: Request with no games
|
||
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let stadiums = [stadiumId: stadium]
|
||
|
||
let request = makeScenarioARequest(
|
||
startDate: makeDate(day: 4, hour: 0),
|
||
endDate: makeDate(day: 10, hour: 23),
|
||
games: [], // No games!
|
||
stadiums: stadiums
|
||
)
|
||
|
||
// Execute
|
||
let result = makeEngine().planItineraries(request: request)
|
||
|
||
// Verify: Should fail with noGamesInRange
|
||
#expect(!result.isSuccess, "Should fail with empty game list")
|
||
#expect(result.failure?.reason == .noGamesInRange,
|
||
"Should return noGamesInRange for empty input")
|
||
}
|
||
}
|