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>
255 lines
11 KiB
Swift
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")
|
|
}
|
|
}
|