480 lines
20 KiB
Swift
480 lines
20 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
|
|
|
|
@MainActor
|
|
final class TravelPlacementTests: XCTestCase {
|
|
|
|
// MARK: - Helpers
|
|
|
|
private let calendar = TestClock.calendar
|
|
|
|
/// 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")
|
|
}
|
|
}
|
|
|
|
// MARK: - Same-Day Collision (dictionary overwrite bug)
|
|
|
|
func test_twoSegmentsSameDay_neitherLost() {
|
|
// 3 back-to-back single-game stops with next-morning departures:
|
|
// Stop A: arrival May 1, departure May 2
|
|
// Stop B: arrival May 2, departure May 3
|
|
// Stop C: arrival May 3, departure May 4
|
|
//
|
|
// Segment 0 (A→B): fromDayNum=2, toDayNum=2 → defaultDay=2, clampedDefault=2
|
|
// Segment 1 (B→C): fromDayNum=3, toDayNum=3 → defaultDay=3, clampedDefault=3
|
|
//
|
|
// These don't collide, but a tighter scenario does:
|
|
// If A departs May 2 and B arrives May 2 AND departs May 2, C arrives May 2:
|
|
// Both segments resolve to Day 2 → collision.
|
|
//
|
|
// Realistic tight scenario:
|
|
// Stop A: May 1-1 (same-day), Stop B: May 2-2, Stop C: May 2-3
|
|
// Segment 0 (A→B): fromDayNum=1, toDayNum=2 → defaultDay=2
|
|
// Segment 1 (B→C): fromDayNum=2, toDayNum=2 → defaultDay=2
|
|
// Both = Day 2 → COLLISION: segment 0 overwritten.
|
|
let stops = [
|
|
makeStop(city: "CityA", arrival: may(1), departure: may(1)),
|
|
makeStop(city: "CityB", arrival: may(2), departure: may(2)),
|
|
makeStop(city: "CityC", arrival: may(2), departure: may(3))
|
|
]
|
|
let segments = [
|
|
makeSegment(from: "CityA", to: "CityB"),
|
|
makeSegment(from: "CityB", to: "CityC")
|
|
]
|
|
|
|
let trip = Trip(
|
|
name: "Tight 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)
|
|
|
|
// Both segments must be present — neither should be silently dropped
|
|
let allSegments = result.values.flatMap { $0 }
|
|
XCTAssertEqual(allSegments.count, 2, "Both travel segments must be preserved (no collision)")
|
|
|
|
// Day 2 should have both segments
|
|
let day2Segments = result[2] ?? []
|
|
XCTAssertEqual(day2Segments.count, 2, "Day 2 should have 2 travel segments")
|
|
}
|
|
|
|
// MARK: - Repeat City Pair ID Collision
|
|
|
|
func test_repeatCityPair_overridesDoNotCollide() {
|
|
// Follow Team pattern: Houston→Cincinnati→Houston→Cincinnati
|
|
// Two segments share the same city pair (Houston→Cincinnati)
|
|
// Each must get a unique travel anchor ID so overrides don't collide.
|
|
_ = [
|
|
makeStop(city: "Houston", arrival: may(1), departure: may(2)), // Stop 0
|
|
makeStop(city: "Cincinnati", arrival: may(4), departure: may(5)), // Stop 1
|
|
makeStop(city: "Houston", arrival: may(7), departure: may(8)), // Stop 2
|
|
makeStop(city: "Cincinnati", arrival: may(10), departure: may(11)) // Stop 3
|
|
]
|
|
|
|
_ = [
|
|
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: "Cincinnati") // Seg 2: stops[2] → stops[3]
|
|
]
|
|
|
|
// Simulate what stableTravelAnchorId should produce WITH segment index
|
|
let id0 = "travel:0:houston->cincinnati"
|
|
let id1 = "travel:1:cincinnati->houston"
|
|
let id2 = "travel:2:houston->cincinnati"
|
|
|
|
// All IDs must be unique
|
|
XCTAssertNotEqual(id0, id2, "Repeat city pair segments must have unique IDs")
|
|
XCTAssertEqual(Set([id0, id1, id2]).count, 3, "All 3 travel IDs must be unique")
|
|
|
|
// Simulate overrides dictionary — each segment gets its own entry
|
|
var overrides: [String: Int] = [:] // travel ID → day override
|
|
overrides[id0] = 3 // Seg 0 overridden to day 3
|
|
overrides[id2] = 9 // Seg 2 overridden to day 9
|
|
|
|
// Verify no collision — seg 0 override is NOT overwritten by seg 2
|
|
XCTAssertEqual(overrides[id0], 3, "Segment 0 override should be day 3")
|
|
XCTAssertEqual(overrides[id2], 9, "Segment 2 override should be day 9")
|
|
XCTAssertNil(overrides[id1], "Segment 1 has no override")
|
|
}
|
|
|
|
func test_singleSegmentPerDay_returnsArrayOfOne() {
|
|
// Ensure the new array return type still works for the simple case
|
|
let stops = [
|
|
makeStop(city: "Houston", arrival: may(1), departure: may(3)),
|
|
makeStop(city: "Chicago", arrival: may(5), departure: may(6))
|
|
]
|
|
let segments = [makeSegment(from: "Houston", to: "Chicago")]
|
|
|
|
let trip = Trip(
|
|
name: "Simple",
|
|
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 day with travel")
|
|
let day4Segments = result[4] ?? []
|
|
XCTAssertEqual(day4Segments.count, 1, "Day 4 should have exactly 1 travel segment")
|
|
}
|
|
}
|