Files
Sportstime/SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift
Trey t 72447c61fe 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>
2026-01-18 20:04:52 -06:00

154 lines
7.1 KiB
Swift

//
// 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")
}
}