Files
Sportstime/SportsTimeTests/Features/Trip/ItinerarySortOrderTests.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

255 lines
11 KiB
Swift

//
// ItinerarySortOrderTests.swift
// SportsTimeTests
//
// Tests for sort order calculation (midpoint insertion algorithm).
//
import XCTest
@testable import SportsTime
private typealias H = ItineraryTestHelpers
final class ItinerarySortOrderTests: XCTestCase {
private let testDate = H.testDate
// MARK: - Midpoint Insertion Tests
func test_sortOrder_dropBetweenItems_usesMidpoint() {
// Given: Two items with sortOrder 1.0 and 3.0
// When: Dropping between them
// Then: New item should get sortOrder 2.0 (midpoint)
let item1 = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "First")
let item2 = H.makeCustomItem(day: 1, sortOrder: 3.0, title: "Third")
let movingItem = H.makeCustomItem(day: 1, sortOrder: 5.0, title: "Moving")
let dayData = ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: [],
items: [.customItem(item1), .customItem(item2), .customItem(movingItem)],
travelBefore: nil
)
let controller = ItineraryTableViewController(style: .plain)
var capturedSortOrder: Double = 0
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item1, item2, movingItem])
// Simulate move: row 3 (movingItem) to row 2 (between item1 and item2)
// Rows: 0=header, 1=item1, 2=item2, 3=movingItem
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 2, section: 0))
// The new sortOrder should be midpoint between 1.0 and 3.0 = 2.0
XCTAssertEqual(capturedSortOrder, 2.0, accuracy: 0.01, "Sort order should be midpoint (2.0)")
}
func test_sortOrder_dropAtEnd_incrementsLastSortOrder() {
// Given: An item with sortOrder 2.0
// When: Dropping after it
// Then: New item should get sortOrder 3.0 (last + 1.0)
let existingItem = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Existing")
let movingItem = H.makeCustomItem(day: 2, sortOrder: 1.0, title: "Moving")
let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [.customItem(existingItem)], travelBefore: nil)
let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [.customItem(movingItem)], travelBefore: nil)
let controller = ItineraryTableViewController(style: .plain)
var capturedSortOrder: Double = 0
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [existingItem, movingItem])
// Move item from Day 2 to end of Day 1
// Rows: 0=Day1 header, 1=existingItem, 2=Day2 header, 3=movingItem
// Move row 3 to row 2 (after existingItem, before Day2 header)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 2, section: 0))
// New sortOrder should be 2.0 + 1.0 = 3.0
XCTAssertEqual(capturedSortOrder, 3.0, accuracy: 0.01, "Sort order should be last + 1.0 = 3.0")
}
func test_sortOrder_dropAsFirstItem_halvesPreviousSortOrder() {
// Given: An item with sortOrder 2.0
// When: Dropping before it as first item
// Then: New item should get sortOrder 1.0 (first / 2.0)
let existingItem = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Existing")
let movingItem = H.makeCustomItem(day: 2, sortOrder: 1.0, title: "Moving")
let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [.customItem(existingItem)], travelBefore: nil)
let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [.customItem(movingItem)], travelBefore: nil)
let controller = ItineraryTableViewController(style: .plain)
var capturedSortOrder: Double = 0
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [existingItem, movingItem])
// Move item from Day 2 to before existingItem
// Rows: 0=Day1 header, 1=existingItem, 2=Day2 header, 3=movingItem
// Move row 3 to row 1 (before existingItem, after header)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 1, section: 0))
// New sortOrder should be 2.0 / 2.0 = 1.0
XCTAssertEqual(capturedSortOrder, 1.0, accuracy: 0.01, "Sort order should be first / 2.0 = 1.0")
}
func test_sortOrder_emptyDay_defaultsTo1() {
// Given: An empty day
// When: Dropping first item
// Then: Sort order should be 1.0
let movingItem = H.makeCustomItem(day: 2, sortOrder: 5.0, title: "Moving")
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: [.customItem(movingItem)], travelBefore: nil)
let controller = ItineraryTableViewController(style: .plain)
var capturedSortOrder: Double = 0
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [movingItem])
// Move item to empty Day 1
// Rows: 0=Day1 header, 1=Day2 header, 2=movingItem
// Move row 2 to row 1 (after Day1 header)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 2, section: 0), to: IndexPath(row: 1, section: 0))
XCTAssertEqual(capturedSortOrder, 1.0, accuracy: 0.01, "Sort order on empty day should be 1.0")
}
// MARK: - scanForward Bug Tests
/// This test explicitly targets the scanForward(from: row) bug.
/// After inserting the moved item at `row`, scanForward finds THE MOVED ITEM ITSELF
/// and returns its old sortOrder instead of the item that should come after.
func test_sortOrder_scanForwardBug_shouldNotFindMovedItemItself() {
// Given: Items with sortOrders 10.0, 20.0, 30.0
// When: Moving item at 30.0 to between 10.0 and 20.0
// Expected: New sortOrder = (10.0 + 20.0) / 2 = 15.0
// Actual Bug: scanForward finds moved item (30.0), returns (10.0 + 30.0) / 2 = 20.0
let item1 = H.makeCustomItem(day: 1, sortOrder: 10.0, title: "A")
let item2 = H.makeCustomItem(day: 1, sortOrder: 20.0, title: "B")
let movingItem = H.makeCustomItem(day: 1, sortOrder: 30.0, title: "Moving")
let dayData = ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: [],
items: [.customItem(item1), .customItem(item2), .customItem(movingItem)],
travelBefore: nil
)
let controller = ItineraryTableViewController(style: .plain)
var capturedSortOrder: Double = 0
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item1, item2, movingItem])
// Rows: 0=header, 1=item1(10), 2=item2(20), 3=movingItem(30)
// Move row 3 to row 2 (between item1 and item2)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 2, section: 0))
// Expected: midpoint of 10.0 and 20.0 = 15.0
// Bug produces: midpoint of 10.0 and 30.0 = 20.0
XCTAssertEqual(capturedSortOrder, 15.0, accuracy: 0.01,
"Sort order should be midpoint of surrounding items (15.0), not including moved item's old sortOrder")
}
// MARK: - Precision Tests
func test_sortOrder_afterManyMidpointInsertions_maintainsPrecision() {
// Verify that many midpoint insertions don't cause precision issues
var sortOrders: [Double] = [1.0, 2.0]
// Insert between 1.0 and 2.0 repeatedly (simulating many reorders)
for _ in 0..<50 {
let midpoint = (sortOrders[0] + sortOrders[1]) / 2.0
sortOrders.insert(midpoint, at: 1)
}
// All values should still be distinct and properly ordered
for i in 0..<(sortOrders.count - 1) {
XCTAssertLessThan(sortOrders[i], sortOrders[i + 1], "Sort orders should remain properly ordered after many insertions")
XCTAssertNotEqual(sortOrders[i], sortOrders[i + 1], "Sort orders should remain distinct after many insertions")
}
}
// MARK: - Before/After Games Tests
func test_moveItem_beforeGames_getsNegativeSortOrder() {
// Given: A game at sortOrder 0 (implicit), item after game at sortOrder 1.0
// When: Moving item to before games
// Then: Should get negative sortOrder (e.g., -1.0)
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
let item = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "AfterGame")
let dayData = ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: games,
items: [.customItem(item)],
travelBefore: nil
)
var capturedSortOrder: Double = 0
let controller = ItineraryTableViewController(style: .plain)
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item])
// Rows: 0=header, 1=games, 2=item
// Move item (row 2) to row 1 (before games, after header)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 2, section: 0), to: IndexPath(row: 1, section: 0))
XCTAssertLessThan(capturedSortOrder, 0, "Item moved before games should have negative sortOrder")
}
func test_moveItem_afterGames_getsPositiveSortOrder() {
// Given: A game, item before game at sortOrder -1.0
// When: Moving item to after games
// Then: Should get positive sortOrder
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
let item = H.makeCustomItem(day: 1, sortOrder: -1.0, title: "BeforeGame")
let dayData = ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: games,
items: [.customItem(item)],
travelBefore: nil
)
var capturedSortOrder: Double = 0
let controller = ItineraryTableViewController(style: .plain)
controller.onCustomItemMoved = { _, _, sortOrder in
capturedSortOrder = sortOrder
}
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item])
// Rows: 0=header, 1=item(-1.0), 2=games
// Move item (row 1) to row 2 (after games)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 2, section: 0))
XCTAssertGreaterThan(capturedSortOrder, 0, "Item moved after games should have positive sortOrder")
}
}