Travel segments appeared one day too late on featured/suggested trips when stops had next-morning departures. The placement formula double- counted by using fromDayNum+1 as both minDay and defaultDay, then the invalid-range fallback used minDay (the overshooting value) instead of the arrival day. Also replaced TripDetailView's inline copy of the placement logic with TravelPlacement.computeTravelByDay() so the UI uses the same tested algorithm. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
15 KiB
Swift
362 lines
15 KiB
Swift
//
|
|
// TravelPlacementTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// Regression tests for travel segment day placement.
|
|
// Specifically tests that repeat cities (e.g., Follow Team mode)
|
|
// don't cause travel segments to be placed on non-existent days.
|
|
//
|
|
|
|
import XCTest
|
|
@testable import SportsTime
|
|
|
|
final class TravelPlacementTests: XCTestCase {
|
|
|
|
// MARK: - Helpers
|
|
|
|
private let calendar = Calendar.current
|
|
|
|
/// Create a date for May 2026 at a given day number.
|
|
private func may(_ day: Int) -> Date {
|
|
var components = DateComponents()
|
|
components.year = 2026
|
|
components.month = 5
|
|
components.day = day
|
|
return calendar.startOfDay(for: calendar.date(from: components)!)
|
|
}
|
|
|
|
/// Create a date for March 2026 at a given day number.
|
|
private func mar(_ day: Int) -> Date {
|
|
var components = DateComponents()
|
|
components.year = 2026
|
|
components.month = 3
|
|
components.day = day
|
|
return calendar.startOfDay(for: calendar.date(from: components)!)
|
|
}
|
|
|
|
/// Build an array of trip days from start to end (inclusive).
|
|
private func tripDays(from start: Date, to end: Date) -> [Date] {
|
|
var days: [Date] = []
|
|
var current = calendar.startOfDay(for: start)
|
|
let endDay = calendar.startOfDay(for: end)
|
|
while current <= endDay {
|
|
days.append(current)
|
|
current = calendar.date(byAdding: .day, value: 1, to: current)!
|
|
}
|
|
return days
|
|
}
|
|
|
|
private func makeStop(city: String, arrival: Date, departure: Date, games: [String] = []) -> TripStop {
|
|
TripStop(
|
|
stopNumber: 1,
|
|
city: city,
|
|
state: "XX",
|
|
arrivalDate: arrival,
|
|
departureDate: departure,
|
|
games: games
|
|
)
|
|
}
|
|
|
|
private func makeSegment(from: String, to: String) -> TravelSegment {
|
|
TravelSegment(
|
|
fromLocation: LocationInput(name: from, coordinate: nil),
|
|
toLocation: LocationInput(name: to, coordinate: nil),
|
|
travelMode: .drive,
|
|
distanceMeters: 500_000,
|
|
durationSeconds: 18000
|
|
)
|
|
}
|
|
|
|
// MARK: - dayNumber Tests
|
|
|
|
func test_dayNumber_returnsCorrectDayForDateWithinTrip() {
|
|
let days = tripDays(from: may(1), to: may(5))
|
|
|
|
XCTAssertEqual(TravelPlacement.dayNumber(for: may(1), in: days), 1)
|
|
XCTAssertEqual(TravelPlacement.dayNumber(for: may(3), in: days), 3)
|
|
XCTAssertEqual(TravelPlacement.dayNumber(for: may(5), in: days), 5)
|
|
}
|
|
|
|
func test_dayNumber_returnsZeroForDateBeforeTrip() {
|
|
let days = tripDays(from: may(5), to: may(10))
|
|
XCTAssertEqual(TravelPlacement.dayNumber(for: may(3), in: days), 0)
|
|
}
|
|
|
|
func test_dayNumber_returnsCountPlusOneForDateAfterTrip() {
|
|
let days = tripDays(from: may(1), to: may(5))
|
|
XCTAssertEqual(TravelPlacement.dayNumber(for: may(8), in: days), 6) // count + 1
|
|
}
|
|
|
|
// MARK: - Simple Trip (no repeat cities)
|
|
|
|
func test_simpleTwoStopTrip_travelPlacedCorrectly() {
|
|
// Chicago (May 1-3) → Detroit (May 5-6)
|
|
// Travel should be on Day 4 (May 4)
|
|
let stops = [
|
|
makeStop(city: "Chicago", arrival: may(1), departure: may(3)),
|
|
makeStop(city: "Detroit", arrival: may(5), departure: may(6))
|
|
]
|
|
let segments = [makeSegment(from: "Chicago", to: "Detroit")]
|
|
|
|
let trip = Trip(
|
|
name: "Test",
|
|
preferences: TripPreferences(),
|
|
stops: stops,
|
|
travelSegments: segments,
|
|
totalGames: 0,
|
|
totalDistanceMeters: 0,
|
|
totalDrivingSeconds: 0
|
|
)
|
|
|
|
let days = tripDays(from: may(1), to: may(6))
|
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
|
|
|
XCTAssertEqual(result.count, 1, "Should have 1 travel segment placed")
|
|
XCTAssertNotNil(result[4], "Travel should be placed on Day 4 (May 4)")
|
|
}
|
|
|
|
// MARK: - Repeat City Bug (Follow Team regression)
|
|
|
|
func test_repeatCity_travelPlacedOnCorrectDay_notGlobalLastGameDay() {
|
|
// Simulates the Astros May 2026 pattern:
|
|
// Houston (May 4-6) → Cincinnati (May 8-10) → Houston (May 11-17) → Chicago (May 22-24) → Houston (May 29-31)
|
|
//
|
|
// BUG: The old code used findLastGameDay("Houston") which returned Day 31,
|
|
// causing Houston→Cincinnati travel to be placed on Day 32 (doesn't exist).
|
|
// FIX: Use stop indices so each segment uses its specific stop's dates.
|
|
|
|
let stops = [
|
|
makeStop(city: "Houston", arrival: may(4), departure: may(6)), // Stop 0
|
|
makeStop(city: "Cincinnati", arrival: may(8), departure: may(10)), // Stop 1
|
|
makeStop(city: "Houston", arrival: may(11), departure: may(17)), // Stop 2
|
|
makeStop(city: "Chicago", arrival: may(22), departure: may(24)), // Stop 3
|
|
makeStop(city: "Houston", arrival: may(29), departure: may(31)) // Stop 4
|
|
]
|
|
|
|
let segments = [
|
|
makeSegment(from: "Houston", to: "Cincinnati"), // Seg 0: stops[0] → stops[1]
|
|
makeSegment(from: "Cincinnati", to: "Houston"), // Seg 1: stops[1] → stops[2]
|
|
makeSegment(from: "Houston", to: "Chicago"), // Seg 2: stops[2] → stops[3]
|
|
makeSegment(from: "Chicago", to: "Houston") // Seg 3: stops[3] → stops[4]
|
|
]
|
|
|
|
let trip = Trip(
|
|
name: "Follow Astros",
|
|
preferences: TripPreferences(),
|
|
stops: stops,
|
|
travelSegments: segments,
|
|
totalGames: 0,
|
|
totalDistanceMeters: 0,
|
|
totalDrivingSeconds: 0
|
|
)
|
|
|
|
let days = tripDays(from: may(4), to: may(31))
|
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
|
|
|
// All 4 segments should be placed within the trip's day range
|
|
XCTAssertEqual(result.count, 4, "All 4 travel segments should be placed")
|
|
|
|
// Houston→Cincinnati: departure May 6 (Day 3), arrival May 8 (Day 5) → travel on Day 4 (May 7)
|
|
XCTAssertNotNil(result[4], "Houston→Cincinnati travel should be on Day 4 (May 7)")
|
|
|
|
// Cincinnati→Houston: departure May 10 (Day 7), arrival May 11 (Day 8) → travel on Day 8 (May 11)
|
|
XCTAssertNotNil(result[8], "Cincinnati→Houston travel should be on Day 8 (May 11)")
|
|
|
|
// Houston→Chicago: departure May 17 (Day 14), arrival May 22 (Day 19) → travel on Day 15 (May 18)
|
|
XCTAssertNotNil(result[15], "Houston→Chicago travel should be on Day 15 (May 18)")
|
|
|
|
// Chicago→Houston: departure May 24 (Day 21), arrival May 29 (Day 26) → travel on Day 22 (May 25)
|
|
XCTAssertNotNil(result[22], "Chicago→Houston travel should be on Day 22 (May 25)")
|
|
|
|
// Critical regression check: NO travel should be placed beyond the trip's day range
|
|
for (day, _) in result {
|
|
XCTAssertGreaterThanOrEqual(day, 1, "Travel day should be >= 1")
|
|
XCTAssertLessThanOrEqual(day, days.count, "Travel day should be <= \(days.count), but got \(day)")
|
|
}
|
|
}
|
|
|
|
func test_repeatCity_threeVisits_allTravelSegmentsVisible() {
|
|
// A→B→A→B pattern (city visited 2x each)
|
|
// A (Day 1-2) → B (Day 4-5) → A (Day 7-8) → B (Day 10-11)
|
|
let stops = [
|
|
makeStop(city: "CityA", arrival: may(1), departure: may(2)),
|
|
makeStop(city: "CityB", arrival: may(4), departure: may(5)),
|
|
makeStop(city: "CityA", arrival: may(7), departure: may(8)),
|
|
makeStop(city: "CityB", arrival: may(10), departure: may(11))
|
|
]
|
|
|
|
let segments = [
|
|
makeSegment(from: "CityA", to: "CityB"),
|
|
makeSegment(from: "CityB", to: "CityA"),
|
|
makeSegment(from: "CityA", to: "CityB")
|
|
]
|
|
|
|
let trip = Trip(
|
|
name: "Test Repeat",
|
|
preferences: TripPreferences(),
|
|
stops: stops,
|
|
travelSegments: segments,
|
|
totalGames: 0,
|
|
totalDistanceMeters: 0,
|
|
totalDrivingSeconds: 0
|
|
)
|
|
|
|
let days = tripDays(from: may(1), to: may(11))
|
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
|
|
|
XCTAssertEqual(result.count, 3, "All 3 travel segments should be placed")
|
|
|
|
// All within bounds
|
|
for (day, _) in result {
|
|
XCTAssertGreaterThanOrEqual(day, 1)
|
|
XCTAssertLessThanOrEqual(day, 11)
|
|
}
|
|
}
|
|
|
|
// MARK: - Consecutive Stops (no gap)
|
|
|
|
func test_consecutiveStops_travelStillPlaced() {
|
|
// Houston (May 1) → Chicago (May 2) - back to back, no rest day
|
|
let stops = [
|
|
makeStop(city: "Houston", arrival: may(1), departure: may(1)),
|
|
makeStop(city: "Chicago", arrival: may(2), departure: may(2))
|
|
]
|
|
let segments = [makeSegment(from: "Houston", to: "Chicago")]
|
|
|
|
let trip = Trip(
|
|
name: "Quick Trip",
|
|
preferences: TripPreferences(),
|
|
stops: stops,
|
|
travelSegments: segments,
|
|
totalGames: 0,
|
|
totalDistanceMeters: 0,
|
|
totalDrivingSeconds: 0
|
|
)
|
|
|
|
let days = tripDays(from: may(1), to: may(2))
|
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
|
|
|
XCTAssertEqual(result.count, 1, "Travel segment should be placed")
|
|
XCTAssertNotNil(result[2], "Travel should be on Day 2 (arrival day)")
|
|
}
|
|
|
|
// MARK: - Next-Day Departure Bug (Featured/Suggested trips)
|
|
|
|
func test_nextDayDeparture_travelPlacedOnArrivalDay() {
|
|
// The exact bug scenario from featured trips:
|
|
// Clearwater arrives May 1, departs May 2 (next-morning departure)
|
|
// New Orleans arrives May 2, departs May 3
|
|
//
|
|
// BUG: fromDayNum=2 (May 2), minDay=fromDayNum+1=3, toDayNum=2 (May 2)
|
|
// minDay(3) > maxDay(2) → fallback uses minDay=3 → travel on Day 3 (WRONG)
|
|
// FIX: Travel should be on Day 2 (the arrival day)
|
|
let stops = [
|
|
makeStop(city: "Clearwater", arrival: may(1), departure: may(2)),
|
|
makeStop(city: "New Orleans", arrival: may(2), departure: may(3))
|
|
]
|
|
let segments = [makeSegment(from: "Clearwater", to: "New Orleans")]
|
|
|
|
let trip = Trip(
|
|
name: "Featured Trip",
|
|
preferences: TripPreferences(),
|
|
stops: stops,
|
|
travelSegments: segments,
|
|
totalGames: 0,
|
|
totalDistanceMeters: 0,
|
|
totalDrivingSeconds: 0
|
|
)
|
|
|
|
let days = tripDays(from: may(1), to: may(3))
|
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
|
|
|
XCTAssertEqual(result.count, 1, "Should have 1 travel segment placed")
|
|
XCTAssertNotNil(result[2], "Travel should be on Day 2 (arrival day, not Day 3)")
|
|
XCTAssertNil(result[3], "Travel should NOT be on Day 3 (the overshoot)")
|
|
}
|
|
|
|
func test_nextDayDeparture_withGap_travelPlacedCorrectly() {
|
|
// Next-day departure with a multi-day gap to next stop:
|
|
// Stop A arrives May 1, departs May 2 (next morning)
|
|
// Stop B arrives May 5, departs May 6
|
|
//
|
|
// fromDayNum=2, minDay=3, toDayNum=5 → minDay(3) <= maxDay(5) → travel on Day 3
|
|
// But ideally travel should be on Day 2 (the departure day itself).
|
|
// After fix: defaultDay = min(fromDayNum+1, toDayNum) = min(3, 5) = 3
|
|
// This is still valid (day 3 is within range), but the key fix is the
|
|
// invalid-range case doesn't apply here.
|
|
let stops = [
|
|
makeStop(city: "StopA", arrival: may(1), departure: may(2)),
|
|
makeStop(city: "StopB", arrival: may(5), departure: may(6))
|
|
]
|
|
let segments = [makeSegment(from: "StopA", to: "StopB")]
|
|
|
|
let trip = Trip(
|
|
name: "Gap Trip",
|
|
preferences: TripPreferences(),
|
|
stops: stops,
|
|
travelSegments: segments,
|
|
totalGames: 0,
|
|
totalDistanceMeters: 0,
|
|
totalDrivingSeconds: 0
|
|
)
|
|
|
|
let days = tripDays(from: may(1), to: may(6))
|
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
|
|
|
XCTAssertEqual(result.count, 1, "Should have 1 travel segment placed")
|
|
// Travel should be within valid range [3...5], defaulting to Day 3
|
|
let placedDay = result.keys.first!
|
|
XCTAssertGreaterThanOrEqual(placedDay, 2, "Travel should be on or after departure day")
|
|
XCTAssertLessThanOrEqual(placedDay, 5, "Travel should be on or before arrival day")
|
|
}
|
|
|
|
func test_threeStops_nextDayDeparture_allSegmentsPlaced() {
|
|
// Full featured trip pattern with 3 stops, all next-day departures:
|
|
// Stop A: arrival Mar 15, departure Mar 16
|
|
// Stop B: arrival Mar 16, departure Mar 17
|
|
// Stop C: arrival Mar 20, departure Mar 21
|
|
//
|
|
// Segment 0 (A→B): fromDayNum=2 (Mar 16), toDayNum=2 (Mar 16)
|
|
// BUG: minDay=3, maxDay=2 → fallback to minDay=3 (Day 3 = Mar 17) WRONG
|
|
// FIX: Travel on Day 2 (Mar 16, arrival day)
|
|
//
|
|
// Segment 1 (B→C): fromDayNum=3 (Mar 17), toDayNum=6 (Mar 20)
|
|
// minDay=4, maxDay=6 → travel on Day 4 (Mar 18) — this works correctly
|
|
let stops = [
|
|
makeStop(city: "StopA", arrival: mar(15), departure: mar(16)),
|
|
makeStop(city: "StopB", arrival: mar(16), departure: mar(17)),
|
|
makeStop(city: "StopC", arrival: mar(20), departure: mar(21))
|
|
]
|
|
let segments = [
|
|
makeSegment(from: "StopA", to: "StopB"),
|
|
makeSegment(from: "StopB", to: "StopC")
|
|
]
|
|
|
|
let trip = Trip(
|
|
name: "Three Stop Featured",
|
|
preferences: TripPreferences(),
|
|
stops: stops,
|
|
travelSegments: segments,
|
|
totalGames: 0,
|
|
totalDistanceMeters: 0,
|
|
totalDrivingSeconds: 0
|
|
)
|
|
|
|
let days = tripDays(from: mar(15), to: mar(21))
|
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
|
|
|
XCTAssertEqual(result.count, 2, "Both travel segments should be placed")
|
|
|
|
// Segment 0 (A→B): should be on Day 2 (Mar 16, the arrival/departure day)
|
|
XCTAssertNotNil(result[2], "A→B travel should be on Day 2 (Mar 16)")
|
|
|
|
// Segment 1 (B→C): should be on Day 3 or 4 (Mar 17 or 18, within valid range)
|
|
let seg1Day = result.first(where: { $0.key != 2 })?.key
|
|
XCTAssertNotNil(seg1Day, "B→C travel should be placed")
|
|
if let day = seg1Day {
|
|
XCTAssertGreaterThanOrEqual(day, 3, "B→C travel should be on or after Day 3")
|
|
XCTAssertLessThanOrEqual(day, 6, "B→C travel should be on or before Day 6")
|
|
}
|
|
}
|
|
}
|