refactor(itinerary): extract reordering logic into pure functions
Extract all itinerary reordering logic from ItineraryTableViewController into ItineraryReorderingLogic.swift for testability. Key changes: - Add flattenDays, dayNumber, travelRow, simulateMove pure functions - Add calculateSortOrder with proper region classification (before/after games) - Add computeValidDestinationRowsProposed with simulation+validation pattern - Add coordinate space conversion helpers (proposedToOriginal, originalToProposed) - Fix DragZones coordinate space mismatch (was mixing proposed/original indices) - Add comprehensive documentation of coordinate space conventions Test coverage includes: - Row flattening order and semantic travel model - Sort order calculation for before/after games regions - Travel constraints validation - DragZones coordinate space correctness - Coordinate conversion helpers - Edge cases (empty days, multi-day trips) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
//
|
||||
// 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-4
|
||||
let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit")
|
||||
|
||||
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: [], travelBefore: travel)
|
||||
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: []
|
||||
)
|
||||
|
||||
// Rows: 0=Day1 header, 1=travel, 2=Day2 header, 3=Day3 header
|
||||
// Move travel (row 1) to row 3 (after Day2, before Day3 header means Day 3)
|
||||
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, 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 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: [], travelBefore: travel)
|
||||
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)
|
||||
|
||||
// Travel is at row 1 (after Day1 header at row 0)
|
||||
// Try to move it to Day 1 area (row 0 or 1) - should snap back to valid range
|
||||
let source = IndexPath(row: 1, 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user