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