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:
153
SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift
Normal file
153
SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// ItineraryCustomItemTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Tests for custom item movement and constraints.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import SportsTime
|
||||
|
||||
private typealias H = ItineraryTestHelpers
|
||||
|
||||
final class ItineraryCustomItemTests: XCTestCase {
|
||||
|
||||
private let testTripId = H.testTripId
|
||||
private let testDate = H.testDate
|
||||
|
||||
// MARK: - Custom Item Movement Tests
|
||||
|
||||
func test_customItem_canMoveToAnyDay() {
|
||||
// Given: A 5-day trip
|
||||
let constraints = ItineraryConstraints(tripDayCount: 5, items: [])
|
||||
let itineraryItem = ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .custom(CustomInfo(title: "Dinner", icon: "🍽️")))
|
||||
|
||||
// Custom items can go on any day
|
||||
for day in 1...5 {
|
||||
XCTAssertTrue(constraints.isValidPosition(for: itineraryItem, day: day, sortOrder: 50), "Custom item should be valid on Day \(day)")
|
||||
}
|
||||
}
|
||||
|
||||
func test_customItem_canMoveBeforeOrAfterGames() {
|
||||
// Given: A day with a game at sortOrder 100
|
||||
let game = H.makeGameItem(city: "Detroit", day: 2, sortOrder: 100)
|
||||
let constraints = ItineraryConstraints(tripDayCount: 3, items: [game])
|
||||
|
||||
let customItem = ItineraryItem(tripId: testTripId, day: 2, sortOrder: 50, kind: .custom(CustomInfo(title: "Breakfast", icon: "🍳")))
|
||||
|
||||
// Before game (sortOrder 50) - VALID
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 50), "Custom item before game should be valid")
|
||||
|
||||
// After game (sortOrder 150) - VALID
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 150), "Custom item after game should be valid")
|
||||
}
|
||||
|
||||
func test_customItem_cannotBeMovedOutsideTripRange() {
|
||||
// Given: A 3-day trip
|
||||
let constraints = ItineraryConstraints(tripDayCount: 3, items: [])
|
||||
let customItem = ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .custom(CustomInfo(title: "Test", icon: "⭐")))
|
||||
|
||||
// Day 0 (before trip) - INVALID
|
||||
XCTAssertFalse(constraints.isValidPosition(for: customItem, day: 0, sortOrder: 50), "Day 0 should be invalid")
|
||||
|
||||
// Day 4 (after trip) - INVALID
|
||||
XCTAssertFalse(constraints.isValidPosition(for: customItem, day: 4, sortOrder: 50), "Day 4 should be invalid (trip is 3 days)")
|
||||
}
|
||||
|
||||
// MARK: - Move Validation Tests
|
||||
|
||||
func test_moveValidation_customItem_blockedFromRow0() {
|
||||
// Row 0 should always be blocked for drops
|
||||
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Moving")
|
||||
let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [.customItem(customItem)], travelBefore: nil)
|
||||
|
||||
let controller = ItineraryTableViewController(style: .plain)
|
||||
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [customItem])
|
||||
|
||||
// Attempt to move item to row 0
|
||||
let source = IndexPath(row: 1, section: 0)
|
||||
let proposed = IndexPath(row: 0, section: 0)
|
||||
|
||||
let result = controller.tableView(controller.tableView, targetIndexPathForMoveFromRowAt: source, toProposedIndexPath: proposed)
|
||||
|
||||
// Should NOT allow row 0
|
||||
XCTAssertNotEqual(result.row, 0, "Row 0 should be blocked for drops")
|
||||
}
|
||||
|
||||
// MARK: - Cross-Day Movement Tests
|
||||
|
||||
func test_moveItemBetweenDays_updatesDay() {
|
||||
// Given: Item on Day 1, move to Day 2
|
||||
let item = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Moving")
|
||||
|
||||
let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [.customItem(item)], travelBefore: nil)
|
||||
let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: nil)
|
||||
|
||||
var capturedDay: Int = 0
|
||||
let controller = ItineraryTableViewController(style: .plain)
|
||||
controller.onCustomItemMoved = { _, day, _ in
|
||||
capturedDay = day
|
||||
}
|
||||
controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [item])
|
||||
|
||||
// Rows: 0=Day1 header, 1=item, 2=Day2 header
|
||||
// Move item (row 1) to after Day2 header (row 2 becomes row 2 after move)
|
||||
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 2, section: 0))
|
||||
|
||||
XCTAssertEqual(capturedDay, 2, "Item should now belong to Day 2")
|
||||
}
|
||||
|
||||
func test_moveItem_fromLastDayToFirstDay() {
|
||||
// Given: 3-day trip with item on Day 3
|
||||
// When: Moving to Day 1
|
||||
// Then: Day should be 1
|
||||
|
||||
let item = H.makeCustomItem(day: 3, sortOrder: 1.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: [], travelBefore: nil)
|
||||
let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [.customItem(item)], travelBefore: nil)
|
||||
|
||||
var capturedDay: Int = 0
|
||||
var capturedSortOrder: Double = 0
|
||||
let controller = ItineraryTableViewController(style: .plain)
|
||||
controller.onCustomItemMoved = { _, day, sortOrder in
|
||||
capturedDay = day
|
||||
capturedSortOrder = sortOrder
|
||||
}
|
||||
controller.reloadData(days: [day1, day2, day3], travelValidRanges: [:], itineraryItems: [item])
|
||||
|
||||
// Rows: 0=Day1 header, 1=Day2 header, 2=Day3 header, 3=item
|
||||
// Move item to after Day1 header
|
||||
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 1, section: 0))
|
||||
|
||||
XCTAssertEqual(capturedDay, 1, "Item should now be on Day 1")
|
||||
XCTAssertEqual(capturedSortOrder, 1.0, accuracy: 0.01, "First item on empty day should get sortOrder 1.0")
|
||||
}
|
||||
|
||||
func test_moveItem_acrossMultipleDays_withGames() {
|
||||
// Given: Item on Day 3, games on Day 1
|
||||
// When: Moving to Day 1 (after games)
|
||||
|
||||
let game1 = H.makeRichGame(city: "Detroit", hour: 14)
|
||||
let item = H.makeCustomItem(day: 3, sortOrder: 1.0, title: "Moving")
|
||||
|
||||
let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [game1], items: [], travelBefore: nil)
|
||||
let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: nil)
|
||||
let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [.customItem(item)], travelBefore: nil)
|
||||
|
||||
var capturedDay: Int = 0
|
||||
let controller = ItineraryTableViewController(style: .plain)
|
||||
controller.onCustomItemMoved = { _, day, _ in
|
||||
capturedDay = day
|
||||
}
|
||||
controller.reloadData(days: [day1, day2, day3], travelValidRanges: [:], itineraryItems: [item])
|
||||
|
||||
// Rows: 0=Day1 header, 1=games, 2=Day2 header, 3=Day3 header, 4=item
|
||||
// Move item to row 2 (after Day1 games, before Day2 header)
|
||||
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 4, section: 0), to: IndexPath(row: 2, section: 0))
|
||||
|
||||
XCTAssertEqual(capturedDay, 1, "Item moved after Day 1 games should be on Day 1")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user