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>
184 lines
7.4 KiB
Swift
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")
|
|
}
|
|
}
|