feat: fix travel placement bug, add theme-based alternate icons, fix animated background crash
- Fix repeat-city travel placement: use stop indices instead of global city name matching so Follow Team trips with repeat cities show travel correctly - Add TravelPlacement helper and regression tests (7 tests) - Add alternate app icons for each theme, auto-switch on theme change - Fix index-out-of-range crash in AnimatedSportsBackground (19 configs, was iterating 20) - Add marketing video configs, engine, and new video components - Add docs and data exports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
233
SportsTimeTests/Features/Trip/TravelPlacementTests.swift
Normal file
233
SportsTimeTests/Features/Trip/TravelPlacementTests.swift
Normal file
@@ -0,0 +1,233 @@
|
||||
//
|
||||
// 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)!)
|
||||
}
|
||||
|
||||
/// 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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user