Files
Sportstime/SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift
Trey t 72447c61fe 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>
2026-01-18 20:04:52 -06:00

184 lines
7.4 KiB
Swift

//
// ItineraryReorderingTests.swift
// SportsTimeTests
//
// Tests for item reordering within and across days.
//
import XCTest
@testable import SportsTime
private typealias H = ItineraryTestHelpers
final class ItineraryReorderingTests: XCTestCase {
private let testDate = H.testDate
// MARK: - Same Day Reordering Tests
func test_reorderItems_withinSameDay_preservesCorrectOrder() {
// Given: 3 items on Day 1: A(1.0), B(2.0), C(3.0)
// When: Move C between A and B
// Then: New sortOrder for C should be 1.5
let itemA = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A")
let itemB = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "B")
let itemC = H.makeCustomItem(day: 1, sortOrder: 3.0, title: "C")
let dayData = ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: [],
items: [.customItem(itemA), .customItem(itemB), .customItem(itemC)],
travelBefore: nil
)
var capturedSortOrder: Double = 0
let controller = ItineraryTableViewController(style: .plain)
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [itemA, itemB, itemC])
// Rows: 0=header, 1=A(1.0), 2=B(2.0), 3=C(3.0)
// Move C (row 3) to row 2 (between A and B)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 2, section: 0))
XCTAssertEqual(capturedSortOrder, 1.5, accuracy: 0.01,
"Moving C between A(1.0) and B(2.0) should give sortOrder 1.5")
}
func test_reorderItems_moveFirstToLast() {
// Given: Items A(1.0), B(2.0), C(3.0)
// When: Move A after C
// Then: New sortOrder for A should be 4.0 (last + 1.0)
let itemA = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A")
let itemB = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "B")
let itemC = H.makeCustomItem(day: 1, sortOrder: 3.0, title: "C")
let dayData = ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: [],
items: [.customItem(itemA), .customItem(itemB), .customItem(itemC)],
travelBefore: nil
)
var capturedSortOrder: Double = 0
let controller = ItineraryTableViewController(style: .plain)
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [itemA, itemB, itemC])
// Rows: 0=header, 1=A(1.0), 2=B(2.0), 3=C(3.0)
// Move A (row 1) to row 3 (after C)
// After removing A: 0=header, 1=B, 2=C
// Insert at row 3: 0=header, 1=B, 2=C, 3=A
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 3, section: 0))
XCTAssertEqual(capturedSortOrder, 4.0, accuracy: 0.01,
"Moving A after C(3.0) should give sortOrder 4.0")
}
func test_reorderItems_moveLastToFirst() {
// Given: Items A(2.0), B(4.0), C(6.0)
// When: Move C before A
// Then: New sortOrder for C should be 1.0 (first / 2.0)
let itemA = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "A")
let itemB = H.makeCustomItem(day: 1, sortOrder: 4.0, title: "B")
let itemC = H.makeCustomItem(day: 1, sortOrder: 6.0, title: "C")
let dayData = ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: [],
items: [.customItem(itemA), .customItem(itemB), .customItem(itemC)],
travelBefore: nil
)
var capturedSortOrder: Double = 0
let controller = ItineraryTableViewController(style: .plain)
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [itemA, itemB, itemC])
// Rows: 0=header, 1=A(2.0), 2=B(4.0), 3=C(6.0)
// Move C (row 3) to row 1 (before A, after header)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 1, section: 0))
XCTAssertEqual(capturedSortOrder, 1.0, accuracy: 0.01,
"Moving C before A(2.0) should give sortOrder 1.0 (first/2)")
}
// MARK: - Non-Reorderable Item Tests
func test_games_cannotBeMoved() {
// Games should always return false for canMoveRowAt
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil)
let controller = ItineraryTableViewController(style: .plain)
controller.reloadData(days: [dayData], travelValidRanges: [:])
// Row 0 = header, Row 1 = games
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 1, section: 0)), "Games should not be movable")
}
func test_header_cannotBeMoved() {
let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil)
let controller = ItineraryTableViewController(style: .plain)
controller.reloadData(days: [dayData], travelValidRanges: [:])
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "Header should not be movable")
}
// MARK: - Callback Tests
func test_moveHeader_doesNotCallCallback() {
// Headers can't be moved, but verify no callback fires
let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil)
var callbackCalled = false
let controller = ItineraryTableViewController(style: .plain)
controller.onCustomItemMoved = { _, _, _ in
callbackCalled = true
}
controller.reloadData(days: [dayData], travelValidRanges: [:])
// Try to move header (shouldn't be possible since canMoveRowAt returns false)
// But if someone calls moveRowAt directly:
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 0, section: 0), to: IndexPath(row: 0, section: 0))
XCTAssertFalse(callbackCalled, "Moving a header should not call any callback")
}
func test_moveGames_doesNotCallCallback() {
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil)
var callbackCalled = false
let controller = ItineraryTableViewController(style: .plain)
controller.onCustomItemMoved = { _, _, _ in
callbackCalled = true
}
controller.onTravelMoved = { _, _, _ in
callbackCalled = true
}
controller.reloadData(days: [dayData], travelValidRanges: [:])
// Rows: 0=header, 1=games
// Try to move games directly (shouldn't be possible)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 0, section: 0))
XCTAssertFalse(callbackCalled, "Moving games should not call any callback")
}
}