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:
254
SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift
Normal file
254
SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift
Normal file
@@ -0,0 +1,254 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user