// // 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") } }