// // ItineraryTravelConstraintTests.swift // SportsTimeTests // // Tests for travel segment movement constraints. // import XCTest @testable import SportsTime private typealias H = ItineraryTestHelpers final class ItineraryTravelConstraintTests: XCTestCase { private let testTripId = H.testTripId private let testDate = H.testDate // MARK: - Travel Constraint Tests func test_travel_cannotMoveBeforeLastDepartureGame() { // Given: Chicago has games on Days 1-2, Detroit has game on Day 4 // Travel Chicago → Detroit valid range: Days 2-4 // Travel cannot be on Day 1 (before last Chicago game) let chicagoGame1 = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) let chicagoGame2 = H.makeGameItem(city: "Chicago", day: 2, sortOrder: 100) let detroitGame = H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100) let constraints = ItineraryConstraints( tripDayCount: 5, items: [chicagoGame1, chicagoGame2, detroitGame] ) let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50) // Travel on Day 1 should be INVALID (Chicago game on Day 2 not finished) XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50), "Travel on Day 1 should be invalid - must wait for Day 2 Chicago game") // Travel on Day 2 after the game should be VALID XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 150), "Travel on Day 2 after game should be valid") } func test_travel_cannotMoveAfterFirstArrivalGame() { // Given: Detroit has games on Days 3-4 // Travel to Detroit must arrive by Day 3 (before first game) let chicagoGame = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) let detroitGame1 = H.makeGameItem(city: "Detroit", day: 3, sortOrder: 100) let detroitGame2 = H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100) let constraints = ItineraryConstraints( tripDayCount: 5, items: [chicagoGame, detroitGame1, detroitGame2] ) let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 4, sortOrder: 50) // Travel on Day 4 should be INVALID (missed Day 3 Detroit game) XCTAssertFalse(constraints.isValidPosition(for: travel, day: 4, sortOrder: 50), "Travel on Day 4 should be invalid - missed Day 3 game") // Travel on Day 3 before the game should be VALID XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50), "Travel on Day 3 before game should be valid") } func test_travel_onEdgeDay_mustRespectSortOrderConstraints() { // Given: Chicago game on Day 1 at sortOrder 100 // Travel on Day 1 must have sortOrder > 100 (after the game) let chicagoGame = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) let constraints = ItineraryConstraints(tripDayCount: 3, items: [chicagoGame]) let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50) // SortOrder 50 is BEFORE the game - INVALID XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50), "Travel before game on same day should be invalid") // SortOrder 100 is AT the game - INVALID (must be strictly after) XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 100), "Travel at same sortOrder as game should be invalid") // SortOrder 150 is AFTER the game - VALID XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150), "Travel after game on same day should be valid") } func test_travel_onArrivalDay_mustBeBeforeGame() { // Given: Detroit game on Day 3 at sortOrder 100 // Travel arriving Day 3 must have sortOrder < 100 (before the game) let detroitGame = H.makeGameItem(city: "Detroit", day: 3, sortOrder: 100) let constraints = ItineraryConstraints(tripDayCount: 3, items: [detroitGame]) let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 150) // SortOrder 150 is AFTER the game - INVALID XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 150), "Travel after game on arrival day should be invalid") // SortOrder 100 is AT the game - INVALID XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 100), "Travel at same sortOrder as arrival game should be invalid") // SortOrder 50 is BEFORE the game - VALID XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50), "Travel before game on arrival day should be valid") } func test_travel_validDayRange_calculatedCorrectly() { // Given: Chicago games Days 1-2, Detroit games Days 4-5 // Travel valid range should be Days 2-4 let games = [ H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100), H.makeGameItem(city: "Chicago", day: 2, sortOrder: 100), H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100), H.makeGameItem(city: "Detroit", day: 5, sortOrder: 100) ] let constraints = ItineraryConstraints(tripDayCount: 6, items: games) let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50) let range = constraints.validDayRange(for: travel) XCTAssertEqual(range, 2...4, "Valid range should be Days 2-4") } func test_travel_impossibleConstraints_returnsNil() { // Given: Chicago game on Day 3, Detroit game on Day 1 // This is impossible - can't leave after Day 3 and arrive by Day 1 let games = [ H.makeGameItem(city: "Chicago", day: 3, sortOrder: 100), H.makeGameItem(city: "Detroit", day: 1, sortOrder: 100) ] let constraints = ItineraryConstraints(tripDayCount: 3, items: games) let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 50) let range = constraints.validDayRange(for: travel) XCTAssertNil(range, "Impossible constraints should return nil range") } // MARK: - Barrier Games Tests func test_barrierGames_identifiesCorrectGames() { // Given: Chicago games Days 1-2, Detroit games Days 4-5 // Barriers should be: last Chicago game (Day 2) and first Detroit game (Day 4) let chicagoGame1 = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) let chicagoGame2 = H.makeGameItem(city: "Chicago", day: 2, sortOrder: 100) let detroitGame1 = H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100) let detroitGame2 = H.makeGameItem(city: "Detroit", day: 5, sortOrder: 100) let constraints = ItineraryConstraints( tripDayCount: 6, items: [chicagoGame1, chicagoGame2, detroitGame1, detroitGame2] ) let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50) let barriers = constraints.barrierGames(for: travel) XCTAssertEqual(barriers.count, 2, "Should identify 2 barrier games") XCTAssertTrue(barriers.contains { $0.id == chicagoGame2.id }, "Should include last Chicago game") XCTAssertTrue(barriers.contains { $0.id == detroitGame1.id }, "Should include first Detroit game") } // MARK: - Travel with Games on Same Day Tests func test_travel_departureDay_sortOrderMustBeAfterLastGame() { // Given: Chicago game at sortOrder 100, travel from Chicago on same day // Travel sortOrder must be > 100 let chicagoGame = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 150) let dayData = ItineraryDayData( id: 1, dayNumber: 1, date: testDate, games: [H.makeRichGame(city: "Chicago", hour: 19)], items: [.travel(H.makeTravelSegment(from: "Chicago", to: "Detroit"), dayNumber: 1)], travelBefore: nil ) let controller = ItineraryTableViewController(style: .plain) var capturedSortOrder: Double = 0 controller.onTravelMoved = { _, _, sortOrder in capturedSortOrder = sortOrder } controller.reloadData( days: [dayData], travelValidRanges: ["travel:chicago->detroit": 1...1], itineraryItems: [chicagoGame, travel] ) // Rows: 0=header, 1=games, 2=travel // Travel is already at valid position, just verify it stays after games XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 2, section: 0))) } // MARK: - Travel Movement Tests func test_travel_moveToValidDay_callsCallback() { // Given: Travel with valid range 2-3 let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2) // Travel model item for sortOrder lookup let travelModelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 1.0) let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [travelItem], travelBefore: nil) let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [], travelBefore: nil) var capturedTravelId: String = "" var capturedDay: Int = 0 let controller = ItineraryTableViewController(style: .plain) controller.onTravelMoved = { travelId, day, _ in capturedTravelId = travelId capturedDay = day } controller.reloadData( days: [day1, day2, day3], travelValidRanges: ["travel:chicago->detroit": 2...3], itineraryItems: [travelModelItem] ) // Rows: 0=Day1 header, 1=Day2 header, 2=travel, 3=Day3 header // Move travel (row 2) to row 3 (after Day3 header = Day 3) controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 2, section: 0), to: IndexPath(row: 3, section: 0)) XCTAssertEqual(capturedTravelId, "travel:chicago->detroit") XCTAssertEqual(capturedDay, 3, "Travel should now be on Day 3") } // MARK: - Move Validation Tests func test_moveValidation_travel_snapsToValidDayRange() { // Given: Travel with valid range Days 2-3 let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") let travelId = "travel:chicago->detroit" let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2) let travelModelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 1.0) let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [travelItem], travelBefore: nil) let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [], travelBefore: nil) let controller = ItineraryTableViewController(style: .plain) let validRanges = [travelId: 2...3] controller.reloadData(days: [day1, day2, day3], travelValidRanges: validRanges, itineraryItems: [travelModelItem]) // Rows: 0=Day1 header, 1=Day2 header, 2=travel, 3=Day3 header // Travel is at row 2 (after Day2 header at row 1) // Try to move it to Day 1 area (row 0) - should snap back to valid range let source = IndexPath(row: 2, section: 0) let proposed = IndexPath(row: 0, section: 0) let result = controller.tableView(controller.tableView, targetIndexPathForMoveFromRowAt: source, toProposedIndexPath: proposed) // Result should NOT be row 0 (Day 1 is outside valid range) XCTAssertGreaterThan(result.row, 0, "Travel should snap away from invalid Day 1") } // MARK: - Complex Scenario func test_complexScenario_multiCityTripWithConstraints() { // Given: A 7-day trip with: // - Chicago games Days 1-2 // - Travel Chicago → Detroit (valid Days 2-4) // - Detroit games Days 4-5 // - Travel Detroit → Milwaukee (valid Days 5-6) // - Milwaukee game Day 6 let chicagoGame1 = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) let chicagoGame2 = H.makeGameItem(city: "Chicago", day: 2, sortOrder: 100) let detroitGame1 = H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100) let detroitGame2 = H.makeGameItem(city: "Detroit", day: 5, sortOrder: 100) let milwaukeeGame = H.makeGameItem(city: "Milwaukee", day: 6, sortOrder: 100) let constraints = ItineraryConstraints( tripDayCount: 7, items: [chicagoGame1, chicagoGame2, detroitGame1, detroitGame2, milwaukeeGame] ) // Travel 1: Chicago → Detroit let travel1 = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50) XCTAssertEqual(constraints.validDayRange(for: travel1), 2...4) // Travel 2: Detroit → Milwaukee let travel2 = H.makeTravelItem(from: "Detroit", to: "Milwaukee", day: 5, sortOrder: 150) XCTAssertEqual(constraints.validDayRange(for: travel2), 5...6) // Invalid positions XCTAssertFalse(constraints.isValidPosition(for: travel1, day: 1, sortOrder: 50), "Travel1 on Day 1 invalid") XCTAssertFalse(constraints.isValidPosition(for: travel1, day: 5, sortOrder: 50), "Travel1 on Day 5 invalid") XCTAssertFalse(constraints.isValidPosition(for: travel2, day: 4, sortOrder: 50), "Travel2 on Day 4 invalid") XCTAssertFalse(constraints.isValidPosition(for: travel2, day: 7, sortOrder: 50), "Travel2 on Day 7 invalid") } }