fix: resolve 4 UI/planning bugs from issue tracker
- Lock all maps to North America (no pan/zoom) in ProgressMapView and TripDetailView - Sort saved trips by most cities (stops count) - Filter cross-country trips to top 2 by stops on home screen - Use LocationSearchSheet for Follow Team home location (consistent with must-stop) - Initialize DateRangePicker to show selected dates on appear Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,16 @@ final class SuggestedTripsGenerator {
|
||||
var tripsByRegion: [(region: Region, trips: [SuggestedTrip])] {
|
||||
let grouped = Dictionary(grouping: suggestedTrips) { $0.region }
|
||||
return Region.allCases.compactMap { region in
|
||||
guard let trips = grouped[region], !trips.isEmpty else { return nil }
|
||||
guard var trips = grouped[region], !trips.isEmpty else { return nil }
|
||||
|
||||
// For cross-country trips, sort by most stops (descending) and limit to top 2
|
||||
if region == .crossCountry {
|
||||
trips = trips
|
||||
.sorted { $0.trip.stops.count > $1.trip.stops.count }
|
||||
.prefix(2)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
return (region: region, trips: trips)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +436,11 @@ struct SavedTripsListView: View {
|
||||
let trips: [SavedTrip]
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
/// Trips sorted by most cities (stops) first
|
||||
private var sortedTrips: [SavedTrip] {
|
||||
trips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if trips.isEmpty {
|
||||
@@ -460,7 +465,7 @@ struct SavedTripsListView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
LazyVStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(Array(trips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||
if let trip = savedTrip.trip {
|
||||
NavigationLink {
|
||||
TripDetailView(trip: trip, games: savedTrip.games)
|
||||
@@ -476,6 +481,7 @@ struct SavedTripsListView: View {
|
||||
}
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("My Trips")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,31 +15,39 @@ struct ProgressMapView: View {
|
||||
let visitStatus: [UUID: StadiumVisitStatus]
|
||||
@Binding var selectedStadium: Stadium?
|
||||
|
||||
@State private var mapRegion = MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), // US center
|
||||
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
|
||||
// Fixed region for continental US - map is locked to this view
|
||||
private let usRegion = MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
|
||||
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 60)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
Map(coordinateRegion: $mapRegion, annotationItems: stadiums) { stadium in
|
||||
MapAnnotation(coordinate: CLLocationCoordinate2D(
|
||||
latitude: stadium.latitude,
|
||||
longitude: stadium.longitude
|
||||
)) {
|
||||
StadiumMapPin(
|
||||
stadium: stadium,
|
||||
isVisited: isVisited(stadium),
|
||||
isSelected: selectedStadium?.id == stadium.id,
|
||||
onTap: {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if selectedStadium?.id == stadium.id {
|
||||
selectedStadium = nil
|
||||
} else {
|
||||
selectedStadium = stadium
|
||||
// Use initialPosition with empty interactionModes to disable pan/zoom
|
||||
// while keeping annotations tappable
|
||||
Map(initialPosition: .region(usRegion), interactionModes: []) {
|
||||
ForEach(stadiums) { stadium in
|
||||
Annotation(
|
||||
stadium.name,
|
||||
coordinate: CLLocationCoordinate2D(
|
||||
latitude: stadium.latitude,
|
||||
longitude: stadium.longitude
|
||||
)
|
||||
) {
|
||||
StadiumMapPin(
|
||||
stadium: stadium,
|
||||
isVisited: isVisited(stadium),
|
||||
isSelected: selectedStadium?.id == stadium.id,
|
||||
onTap: {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if selectedStadium?.id == stadium.id {
|
||||
selectedStadium = nil
|
||||
} else {
|
||||
selectedStadium = stadium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.mapStyle(.standard(elevation: .realistic))
|
||||
|
||||
@@ -209,14 +209,31 @@ struct GameRowView: View {
|
||||
let game: RichGame
|
||||
var showDate: Bool = false
|
||||
|
||||
private var isToday: Bool {
|
||||
Calendar.current.isDateInToday(game.game.dateTime)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Date (when grouped by sport)
|
||||
if showDate {
|
||||
Text(formattedDate)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Text(formattedDate)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isToday {
|
||||
Text("TODAY")
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Teams
|
||||
@@ -247,6 +264,7 @@ struct GameRowView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
|
||||
}
|
||||
|
||||
private var formattedDate: String {
|
||||
|
||||
@@ -36,7 +36,33 @@ final class TripCreationViewModel {
|
||||
|
||||
// MARK: - Planning Mode
|
||||
|
||||
var planningMode: PlanningMode = .dateRange
|
||||
var planningMode: PlanningMode = .dateRange {
|
||||
didSet {
|
||||
// Reset state when mode changes to ensure clean UI transitions
|
||||
if oldValue != planningMode {
|
||||
viewState = .editing
|
||||
// Clear mode-specific selections
|
||||
switch planningMode {
|
||||
case .dateRange, .gameFirst:
|
||||
// Clear locations for date-based modes
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
case .locations:
|
||||
// Keep locations (user needs to enter them)
|
||||
break
|
||||
case .followTeam:
|
||||
// Clear locations and must-see games for follow team mode
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
mustSeeGameIds.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Form Fields
|
||||
|
||||
@@ -61,7 +87,9 @@ final class TripCreationViewModel {
|
||||
var startDate: Date = Date() {
|
||||
didSet {
|
||||
// Clear cached games when start date changes
|
||||
if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) {
|
||||
// BUT: In gameFirst mode, games are the source of truth for dates,
|
||||
// so don't clear them (fixes "date range required" error)
|
||||
if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) && planningMode != .gameFirst {
|
||||
availableGames = []
|
||||
games = []
|
||||
}
|
||||
@@ -70,7 +98,9 @@ final class TripCreationViewModel {
|
||||
var endDate: Date = Date().addingTimeInterval(86400 * 7) {
|
||||
didSet {
|
||||
// Clear cached games when end date changes
|
||||
if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) {
|
||||
// BUT: In gameFirst mode, games are the source of truth for dates,
|
||||
// so don't clear them (fixes "date range required" error)
|
||||
if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) && planningMode != .gameFirst {
|
||||
availableGames = []
|
||||
games = []
|
||||
}
|
||||
@@ -414,32 +444,8 @@ final class TripCreationViewModel {
|
||||
}
|
||||
|
||||
func switchPlanningMode(_ mode: PlanningMode) {
|
||||
// Just set the mode - didSet observer handles state reset
|
||||
planningMode = mode
|
||||
// Clear mode-specific selections when switching
|
||||
switch mode {
|
||||
case .dateRange:
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
case .gameFirst:
|
||||
// Keep games, clear locations
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
case .locations:
|
||||
// Keep locations, optionally keep selected games
|
||||
break
|
||||
|
||||
case .followTeam:
|
||||
// Clear non-follow-team selections
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
mustSeeGameIds.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load games for browsing in game-first mode
|
||||
|
||||
@@ -38,6 +38,7 @@ struct TripCreationView: View {
|
||||
enum CityInputType {
|
||||
case mustStop
|
||||
case preferred
|
||||
case homeLocation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -121,6 +122,9 @@ struct TripCreationView: View {
|
||||
viewModel.addMustStopLocation(location)
|
||||
case .preferred:
|
||||
viewModel.addPreferredCity(location.name)
|
||||
case .homeLocation:
|
||||
viewModel.startLocationText = location.name
|
||||
viewModel.startLocation = location
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -595,38 +599,50 @@ struct TripCreationView: View {
|
||||
)
|
||||
|
||||
if viewModel.useHomeLocation {
|
||||
// Show home location input with suggestions
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ThemedTextField(
|
||||
label: "Home Location",
|
||||
placeholder: "Enter your city",
|
||||
text: $viewModel.startLocationText,
|
||||
icon: "house.fill"
|
||||
)
|
||||
.onChange(of: viewModel.startLocationText) { _, newValue in
|
||||
searchLocation(query: newValue, isStart: true)
|
||||
}
|
||||
// Show button to open location search sheet (same as must-stop)
|
||||
Button {
|
||||
cityInputType = .homeLocation
|
||||
showCityInput = true
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: "house.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
// Suggestions for home location
|
||||
if !startLocationSuggestions.isEmpty {
|
||||
locationSuggestionsList(
|
||||
suggestions: startLocationSuggestions,
|
||||
isLoading: isSearchingStart
|
||||
) { result in
|
||||
viewModel.startLocationText = result.name
|
||||
viewModel.startLocation = result.toLocationInput()
|
||||
startLocationSuggestions = []
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let location = viewModel.startLocation {
|
||||
Text(location.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
if let address = location.address, !address.isEmpty {
|
||||
Text(address)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
} else {
|
||||
Text("Choose home location")
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text("Tap to search cities")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
} else if isSearchingStart {
|
||||
HStack {
|
||||
ThemedSpinnerCompact(size: 14)
|
||||
Text("Searching...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xs)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Text("Trip will start at first game and end at last game (fly-in/fly-out)")
|
||||
.font(.caption)
|
||||
@@ -2099,6 +2115,14 @@ struct DateRangePicker: View {
|
||||
// Trip duration
|
||||
tripDurationBadge
|
||||
}
|
||||
.onAppear {
|
||||
// Initialize displayed month to show the start date's month
|
||||
displayedMonth = calendar.startOfDay(for: startDate)
|
||||
// If dates are already selected (endDate > startDate), show complete state
|
||||
if endDate > startDate {
|
||||
selectionState = .complete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedRangeSummary: some View {
|
||||
|
||||
@@ -157,7 +157,7 @@ struct TripDetailView: View {
|
||||
|
||||
private var heroMapSection: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Map(position: $mapCameraPosition) {
|
||||
Map(position: $mapCameraPosition, interactionModes: []) {
|
||||
ForEach(stopCoordinates.indices, id: \.self) { index in
|
||||
let stop = stopCoordinates[index]
|
||||
Annotation(stop.name, coordinate: stop.coordinate) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import CoreLocation
|
||||
///
|
||||
/// Input:
|
||||
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
|
||||
/// - must_stop: Optional. A location they must visit (not yet implemented)
|
||||
/// - must_stop: Optional. A location they must visit (filters to home games in that city)
|
||||
///
|
||||
/// Output:
|
||||
/// - Success: Ranked list of itinerary options
|
||||
@@ -90,6 +90,37 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2b: Filter by must-stop locations (if any)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// If user specified a must-stop city, filter to HOME games in that city.
|
||||
// A "home game" means the stadium is in the must-stop city.
|
||||
var filteredGames = gamesInRange
|
||||
if let mustStop = request.mustStopLocation {
|
||||
let mustStopCity = mustStop.name.lowercased()
|
||||
filteredGames = gamesInRange.filter { game in
|
||||
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
||||
let stadiumCity = stadium.city.lowercased()
|
||||
// Match if either contains the other (handles "Chicago" vs "Chicago, IL")
|
||||
return stadiumCity.contains(mustStopCity) || mustStopCity.contains(stadiumCity)
|
||||
}
|
||||
|
||||
if filteredGames.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .mustStop,
|
||||
description: "No home games found in \(mustStop.name) during selected dates",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: Find ALL geographically sensible route variations
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
@@ -112,7 +143,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
|
||||
// Global beam search (finds cross-region routes)
|
||||
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
from: filteredGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
@@ -121,7 +152,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
|
||||
// Per-region beam search (ensures good regional options)
|
||||
let regionalRoutes = findRoutesPerRegion(
|
||||
games: gamesInRange,
|
||||
games: filteredGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities
|
||||
)
|
||||
@@ -130,7 +161,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
// Deduplicate routes (same game IDs)
|
||||
validRoutes = deduplicateRoutes(validRoutes)
|
||||
|
||||
print("🔍 ScenarioA: gamesInRange=\(gamesInRange.count), validRoutes=\(validRoutes.count)")
|
||||
print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)")
|
||||
if let firstRoute = validRoutes.first {
|
||||
print("🔍 ScenarioA: First route has \(firstRoute.count) games")
|
||||
let cities = firstRoute.compactMap { request.stadiums[$0.stadiumId]?.city }
|
||||
|
||||
@@ -78,7 +78,8 @@ struct ScenarioAPlannerTests {
|
||||
teams: [UUID: Team] = [:],
|
||||
allowRepeatCities: Bool = true,
|
||||
numberOfDrivers: Int = 1,
|
||||
maxDrivingHoursPerDriver: Double = 8.0
|
||||
maxDrivingHoursPerDriver: Double = 8.0,
|
||||
mustStopLocations: [LocationInput] = []
|
||||
) -> PlanningRequest {
|
||||
let preferences = TripPreferences(
|
||||
planningMode: .dateRange,
|
||||
@@ -86,6 +87,7 @@ struct ScenarioAPlannerTests {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
leisureLevel: .moderate,
|
||||
mustStopLocations: mustStopLocations,
|
||||
numberOfDrivers: numberOfDrivers,
|
||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||
allowRepeatCities: allowRepeatCities
|
||||
@@ -465,4 +467,230 @@ struct ScenarioAPlannerTests {
|
||||
"Should have options with relaxed driver constraints")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 4D: Must-Stop Filtering (Issues #4 & #8)
|
||||
|
||||
@Test("4.10 - Must-stop filters to games in that city")
|
||||
func test_planByDates_MustStop_FiltersToGamesInCity() {
|
||||
// Setup: Games in Chicago, Milwaukee, Detroit
|
||||
// Must-stop = Chicago → should only return Chicago games
|
||||
let chicagoId = UUID()
|
||||
let milwaukeeId = UUID()
|
||||
let detroitId = UUID()
|
||||
|
||||
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 chicagoGame = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||||
let milwaukeeGame = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
|
||||
let detroitGame = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 9, hour: 19))
|
||||
|
||||
let mustStop = LocationInput(name: "Chicago")
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 10, hour: 23),
|
||||
games: [chicagoGame, milwaukeeGame, detroitGame],
|
||||
stadiums: stadiums,
|
||||
mustStopLocations: [mustStop]
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: Should succeed and ONLY include Chicago games
|
||||
#expect(result.isSuccess, "Should succeed with must-stop in Chicago")
|
||||
#expect(!result.options.isEmpty, "Should return at least one option")
|
||||
|
||||
// All games in result should be in Chicago only
|
||||
for option in result.options {
|
||||
let allGameIds = Set(option.stops.flatMap { $0.games })
|
||||
#expect(allGameIds.contains(chicagoGame.id), "Should include Chicago game")
|
||||
#expect(!allGameIds.contains(milwaukeeGame.id), "Should NOT include Milwaukee game")
|
||||
#expect(!allGameIds.contains(detroitGame.id), "Should NOT include Detroit game")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("4.11 - Must-stop with no matching games returns failure")
|
||||
func test_planByDates_MustStop_NoMatchingGames_ReturnsFailure() {
|
||||
// Setup: Games only in Milwaukee and Detroit
|
||||
// Must-stop = Chicago → no games there, should fail
|
||||
let milwaukeeId = UUID()
|
||||
let detroitId = UUID()
|
||||
|
||||
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 = [milwaukeeId: milwaukee, detroitId: detroit]
|
||||
|
||||
let milwaukeeGame = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
|
||||
let detroitGame = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 9, hour: 19))
|
||||
|
||||
let mustStop = LocationInput(name: "Chicago")
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 10, hour: 23),
|
||||
games: [milwaukeeGame, detroitGame],
|
||||
stadiums: stadiums,
|
||||
mustStopLocations: [mustStop]
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: Should fail with noGamesInRange (no home games in Chicago)
|
||||
#expect(!result.isSuccess, "Should fail when no games in must-stop city")
|
||||
#expect(result.failure?.reason == .noGamesInRange,
|
||||
"Should return noGamesInRange failure")
|
||||
}
|
||||
|
||||
@Test("4.12 - Must-stop only returns HOME games (Issue #8)")
|
||||
func test_planByDates_MustStop_OnlyReturnsHomeGames() {
|
||||
// Setup: Cubs home game in Chicago + Cubs away game in Milwaukee (playing at Milwaukee)
|
||||
// Must-stop = Chicago → should ONLY return the Chicago home game
|
||||
// This tests Issue #8: "Must stop needs to be home team"
|
||||
let chicagoStadiumId = UUID()
|
||||
let milwaukeeStadiumId = UUID()
|
||||
let cubsTeamId = UUID()
|
||||
let brewersTeamId = UUID()
|
||||
|
||||
let wrigleyField = makeStadium(id: chicagoStadiumId, city: "Chicago", lat: 41.9484, lon: -87.6553)
|
||||
let millerPark = makeStadium(id: milwaukeeStadiumId, city: "Milwaukee", lat: 43.0280, lon: -87.9712)
|
||||
|
||||
let stadiums = [chicagoStadiumId: wrigleyField, milwaukeeStadiumId: millerPark]
|
||||
|
||||
// Cubs HOME game at Wrigley (Chicago)
|
||||
let cubsHomeGame = makeGame(
|
||||
stadiumId: chicagoStadiumId,
|
||||
homeTeamId: cubsTeamId,
|
||||
awayTeamId: brewersTeamId,
|
||||
dateTime: makeDate(day: 5, hour: 19)
|
||||
)
|
||||
|
||||
// Cubs AWAY game at Miller Park (Milwaukee) - Cubs are playing but NOT at home
|
||||
let cubsAwayGame = makeGame(
|
||||
stadiumId: milwaukeeStadiumId,
|
||||
homeTeamId: brewersTeamId,
|
||||
awayTeamId: cubsTeamId,
|
||||
dateTime: makeDate(day: 7, hour: 19)
|
||||
)
|
||||
|
||||
let mustStop = LocationInput(name: "Chicago")
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 10, hour: 23),
|
||||
games: [cubsHomeGame, cubsAwayGame],
|
||||
stadiums: stadiums,
|
||||
mustStopLocations: [mustStop]
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: Should succeed with ONLY the Chicago home game
|
||||
#expect(result.isSuccess, "Should succeed with Chicago home game")
|
||||
#expect(!result.options.isEmpty, "Should return at least one option")
|
||||
|
||||
// The away game in Milwaukee should NOT be included even though Cubs are playing
|
||||
for option in result.options {
|
||||
let allGameIds = Set(option.stops.flatMap { $0.games })
|
||||
#expect(allGameIds.contains(cubsHomeGame.id), "Should include Cubs HOME game in Chicago")
|
||||
#expect(!allGameIds.contains(cubsAwayGame.id), "Should NOT include Cubs AWAY game in Milwaukee")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("4.13 - Must-stop with partial city name match works")
|
||||
func test_planByDates_MustStop_PartialCityMatch_Works() {
|
||||
// Setup: User types "Chicago" but stadium city is "Chicago, IL"
|
||||
// Should still match via contains
|
||||
let chicagoId = UUID()
|
||||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let stadiums = [chicagoId: chicago]
|
||||
|
||||
let game = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||||
|
||||
// User might type partial name
|
||||
let mustStop = LocationInput(name: "Chicago, IL")
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 10, hour: 23),
|
||||
games: [game],
|
||||
stadiums: stadiums,
|
||||
mustStopLocations: [mustStop]
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: Should still find Chicago games with partial match
|
||||
#expect(result.isSuccess, "Should succeed with partial city name match")
|
||||
#expect(!result.options.isEmpty, "Should return options")
|
||||
}
|
||||
|
||||
@Test("4.14 - Must-stop case insensitive")
|
||||
func test_planByDates_MustStop_CaseInsensitive() {
|
||||
// Setup: Must-stop = "CHICAGO" (uppercase) should match "Chicago"
|
||||
let chicagoId = UUID()
|
||||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let stadiums = [chicagoId: chicago]
|
||||
|
||||
let game = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||||
|
||||
let mustStop = LocationInput(name: "CHICAGO")
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 10, hour: 23),
|
||||
games: [game],
|
||||
stadiums: stadiums,
|
||||
mustStopLocations: [mustStop]
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: Case insensitive match
|
||||
#expect(result.isSuccess, "Should succeed with case-insensitive match")
|
||||
}
|
||||
|
||||
@Test("4.15 - Multiple games in must-stop city all included")
|
||||
func test_planByDates_MustStop_MultipleGamesInCity_AllIncluded() {
|
||||
// Setup: Multiple games in Chicago on different days
|
||||
let chicagoId = UUID()
|
||||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let stadiums = [chicagoId: chicago]
|
||||
|
||||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||||
let game2 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 7, hour: 13))
|
||||
let game3 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 9, hour: 19))
|
||||
|
||||
let mustStop = LocationInput(name: "Chicago")
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 10, hour: 23),
|
||||
games: [game1, game2, game3],
|
||||
stadiums: stadiums,
|
||||
mustStopLocations: [mustStop]
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: All Chicago games should be included
|
||||
#expect(result.isSuccess, "Should succeed with multiple games in must-stop city")
|
||||
|
||||
if let option = result.options.first {
|
||||
let allGameIds = Set(option.stops.flatMap { $0.games })
|
||||
#expect(allGameIds.contains(game1.id), "Should include first Chicago game")
|
||||
#expect(allGameIds.contains(game2.id), "Should include second Chicago game")
|
||||
#expect(allGameIds.contains(game3.id), "Should include third Chicago game")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user