Files
Sportstime/SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift
2026-02-18 13:00:15 -06:00

851 lines
33 KiB
Swift

//
// ItineraryReorderingLogicTests.swift
// SportsTimeTests
//
// Comprehensive tests for ItineraryReorderingLogic pure functions.
// These tests exercise all the business logic without UIKit dependencies.
//
import XCTest
@testable import SportsTime
private typealias H = ItineraryTestHelpers
private typealias Logic = ItineraryReorderingLogic
final class ItineraryReorderingLogicTests: XCTestCase {
private let testDate = H.testDate
private let testTripId = H.testTripId
// MARK: - Test Data Builders
/// Creates a flat items array from a simple DSL.
/// Format: [.day(1), .game("Detroit"), .custom("A", 1.0), .travel("Chi", "Det"), ...]
private func buildFlatItems(_ elements: [TestElement]) -> [ItineraryRowItem] {
var items: [ItineraryRowItem] = []
for element in elements {
switch element {
case .day(let num):
let date = TestClock.calendar.date(byAdding: .day, value: num - 1, to: testDate)!
items.append(.dayHeader(dayNumber: num, date: date))
case .game(let city, let day):
let game = H.makeRichGame(city: city, hour: 19, baseDate: testDate)
items.append(.games([game], dayNumber: day))
case .custom(let title, let sortOrder, let day):
let item = H.makeCustomItem(day: day, sortOrder: sortOrder, title: title)
items.append(.customItem(item))
case .travel(let from, let to, let day):
let segment = H.makeTravelSegment(from: from, to: to)
items.append(.travel(segment, dayNumber: day))
}
}
return items
}
private enum TestElement {
case day(Int)
case game(String, day: Int)
case custom(String, sortOrder: Double, day: Int)
case travel(from: String, to: String, day: Int)
}
// MARK: - nearestValue Tests
func test_nearestValue_emptyArray_returnsNil() {
let result = Logic.nearestValue(in: [], to: 5)
XCTAssertNil(result)
}
func test_nearestValue_singleElement_returnsThatElement() {
XCTAssertEqual(Logic.nearestValue(in: [3], to: 1), 3)
XCTAssertEqual(Logic.nearestValue(in: [3], to: 5), 3)
XCTAssertEqual(Logic.nearestValue(in: [3], to: 3), 3)
}
func test_nearestValue_exactMatch_returnsExact() {
let sorted = [1, 3, 5, 7, 9]
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 5), 5)
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 1), 1)
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 9), 9)
}
func test_nearestValue_betweenValues_returnsCloser() {
let sorted = [1, 5, 10, 20]
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 3), 1) // 3 is closer to 1 than 5
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 4), 5) // 4 is closer to 5 than 1
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 7), 5) // 7 is closer to 5 than 10
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 8), 10) // 8 is closer to 10 than 5
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 15), 10) // 15 is closer to 10 than 20
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 16), 20) // 16 is closer to 20 than 10
}
func test_nearestValue_belowMin_returnsMin() {
let sorted = [5, 10, 15]
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 0), 5)
XCTAssertEqual(Logic.nearestValue(in: sorted, to: -100), 5)
}
func test_nearestValue_aboveMax_returnsMax() {
let sorted = [5, 10, 15]
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 20), 15)
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 100), 15)
}
func test_nearestValue_tieBreaker_prefersLower() {
// When equidistant, should prefer the lower value
let sorted = [1, 5]
XCTAssertEqual(Logic.nearestValue(in: sorted, to: 3), 1) // 3 is equidistant from 1 and 5
}
// MARK: - simulateMove Tests
func test_simulateMove_moveForward() {
// [A, B, C, D] -> Move A to position 2 -> [B, C, A, D]
let items = buildFlatItems([.day(1), .day(2), .day(3), .day(4)])
let result = Logic.simulateMove(original: items, sourceRow: 0, destinationProposedRow: 2)
XCTAssertEqual(result.items.count, 4)
XCTAssertEqual(result.destinationRowInNewArray, 2)
// After removing item at 0, array is [B, C, D] (indices 0, 1, 2)
// Insert at 2 gives [B, C, A, D]
if case .dayHeader(let day1, _) = result.items[0] { XCTAssertEqual(day1, 2) }
if case .dayHeader(let day2, _) = result.items[1] { XCTAssertEqual(day2, 3) }
if case .dayHeader(let day3, _) = result.items[2] { XCTAssertEqual(day3, 1) } // Moved item
if case .dayHeader(let day4, _) = result.items[3] { XCTAssertEqual(day4, 4) }
}
func test_simulateMove_moveBackward() {
// [A, B, C, D] -> Move D to position 1 -> [A, D, B, C]
let items = buildFlatItems([.day(1), .day(2), .day(3), .day(4)])
let result = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: 1)
XCTAssertEqual(result.items.count, 4)
XCTAssertEqual(result.destinationRowInNewArray, 1)
if case .dayHeader(let day1, _) = result.items[0] { XCTAssertEqual(day1, 1) }
if case .dayHeader(let day2, _) = result.items[1] { XCTAssertEqual(day2, 4) } // Moved item
if case .dayHeader(let day3, _) = result.items[2] { XCTAssertEqual(day3, 2) }
if case .dayHeader(let day4, _) = result.items[3] { XCTAssertEqual(day4, 3) }
}
func test_simulateMove_moveToEnd() {
// [A, B, C] -> Move A to end -> [B, C, A]
let items = buildFlatItems([.day(1), .day(2), .day(3)])
let result = Logic.simulateMove(original: items, sourceRow: 0, destinationProposedRow: 2)
XCTAssertEqual(result.destinationRowInNewArray, 2)
if case .dayHeader(let day, _) = result.items[2] { XCTAssertEqual(day, 1) }
}
func test_simulateMove_moveToStart() {
// [A, B, C] -> Move C to start -> [C, A, B]
let items = buildFlatItems([.day(1), .day(2), .day(3)])
let result = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: 0)
XCTAssertEqual(result.destinationRowInNewArray, 0)
if case .dayHeader(let day, _) = result.items[0] { XCTAssertEqual(day, 3) }
}
func test_simulateMove_samePosition() {
// [A, B, C] -> Move B to same position -> [A, B, C]
let items = buildFlatItems([.day(1), .day(2), .day(3)])
let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 1)
// After remove at 1: [A, C], insert at 1: [A, B, C]
XCTAssertEqual(result.destinationRowInNewArray, 1)
if case .dayHeader(let day, _) = result.items[1] { XCTAssertEqual(day, 2) }
}
// MARK: - dayNumber Tests
func test_dayNumber_rowOnHeader_returnsThatDay() {
let items = buildFlatItems([
.day(1),
.game("Detroit", day: 1),
.day(2),
.game("Chicago", day: 2)
])
XCTAssertEqual(Logic.dayNumber(in: items, forRow: 0), 1) // Day 1 header
XCTAssertEqual(Logic.dayNumber(in: items, forRow: 2), 2) // Day 2 header
}
func test_dayNumber_rowAfterHeader_returnsThatDay() {
let items = buildFlatItems([
.day(1),
.game("Detroit", day: 1),
.custom("Lunch", sortOrder: 1.0, day: 1),
.day(2),
.game("Chicago", day: 2)
])
XCTAssertEqual(Logic.dayNumber(in: items, forRow: 1), 1) // Game on day 1
XCTAssertEqual(Logic.dayNumber(in: items, forRow: 2), 1) // Custom on day 1
XCTAssertEqual(Logic.dayNumber(in: items, forRow: 4), 2) // Game on day 2
}
func test_dayNumber_travelBeforeHeader_returnsThatDay() {
let items = buildFlatItems([
.day(1),
.game("Detroit", day: 1),
.travel(from: "Detroit", to: "Chicago", day: 2),
.day(2),
.game("Chicago", day: 2)
])
// Travel at row 2 should return day 1 (scans backward, finds Day 1 header)
XCTAssertEqual(Logic.dayNumber(in: items, forRow: 2), 1)
}
func test_dayNumber_emptyArray_returnsDefault() {
XCTAssertEqual(Logic.dayNumber(in: [], forRow: 0), 1)
XCTAssertEqual(Logic.dayNumber(in: [], forRow: 5), 1)
}
func test_dayNumber_outOfBounds_clampsAndReturns() {
let items = buildFlatItems([.day(1), .day(2)])
// Out of bounds high should clamp and return day 2
XCTAssertEqual(Logic.dayNumber(in: items, forRow: 100), 2)
}
// MARK: - dayHeaderRow Tests
func test_dayHeaderRow_findsCorrectRow() {
let items = buildFlatItems([
.day(1),
.game("Detroit", day: 1),
.day(2),
.game("Chicago", day: 2),
.day(3)
])
XCTAssertEqual(Logic.dayHeaderRow(in: items, forDay: 1), 0)
XCTAssertEqual(Logic.dayHeaderRow(in: items, forDay: 2), 2)
XCTAssertEqual(Logic.dayHeaderRow(in: items, forDay: 3), 4)
}
func test_dayHeaderRow_dayNotFound_returnsNil() {
let items = buildFlatItems([.day(1), .day(2)])
XCTAssertNil(Logic.dayHeaderRow(in: items, forDay: 5))
}
// MARK: - travelRow Tests
func test_travelRow_findsCorrectRow() {
// Semantic model: travelRow finds travel in the section AFTER the day header
// Travel must be positioned within its correct day section
let items = buildFlatItems([
.day(1),
.game("Detroit", day: 1),
.day(2),
.travel(from: "Detroit", to: "Chicago", day: 2), // Row 3: in day 2 section
.day(3),
.travel(from: "Chicago", to: "Milwaukee", day: 3) // Row 5: in day 3 section
])
XCTAssertEqual(Logic.travelRow(in: items, forDay: 2), 3)
XCTAssertEqual(Logic.travelRow(in: items, forDay: 3), 5)
}
func test_travelRow_noTravelOnDay_returnsNil() {
// Travel is in day 2 section, so day 1 has no travel
let items = buildFlatItems([
.day(1),
.day(2),
.travel(from: "Detroit", to: "Chicago", day: 2) // In day 2 section
])
XCTAssertNil(Logic.travelRow(in: items, forDay: 1))
}
// MARK: - dayForTravelAt Tests
func test_dayForTravelAt_usesBackwardScan() {
// Semantic model: travel belongs to the day of the nearest preceding header
let items = buildFlatItems([
.day(1),
.travel(from: "Detroit", to: "Chicago", day: 2), // Row 1
.day(2),
.travel(from: "Chicago", to: "Milwaukee", day: 3), // Row 3
.day(3)
])
// Travel at row 1 finds Day 1 header at row 0 (backward scan)
XCTAssertEqual(Logic.dayForTravelAt(row: 1, in: items), 1)
// Travel at row 3 finds Day 2 header at row 2 (backward scan)
XCTAssertEqual(Logic.dayForTravelAt(row: 3, in: items), 2)
}
func test_dayForTravelAt_lastItem_fallsBackToLastDay() {
let items = buildFlatItems([
.day(1),
.day(2),
.travel(from: "Detroit", to: "Chicago", day: 2) // Travel at end
])
// No header after travel, should fallback scan backward
XCTAssertEqual(Logic.dayForTravelAt(row: 2, in: items), 2)
}
// MARK: - calculateSortOrder Tests (Midpoint Algorithm)
func test_calculateSortOrder_emptyDay_returns1() {
// Day with only header, no items
let items = buildFlatItems([
.day(1),
.day(2)
])
// Simulating drop right after day 1 header (row 0)
// After inserting at row 1, day 1 has no other items
let mockItems = buildFlatItems([
.day(1),
.custom("New", sortOrder: 999, day: 1), // Placeholder for dropped item
.day(2)
])
let sortOrder = Logic.calculateSortOrder(in: mockItems, at: 1) { _ in nil }
XCTAssertEqual(sortOrder, 1.0, accuracy: 0.01)
}
func test_calculateSortOrder_betweenTwoItems_returnsMidpoint() {
// Items at 1.0 and 3.0, drop between them should get 2.0
let items = buildFlatItems([
.day(1),
.custom("A", sortOrder: 1.0, day: 1),
.custom("New", sortOrder: 999, day: 1), // Dropped item at row 2
.custom("B", sortOrder: 3.0, day: 1),
.day(2)
])
let sortOrder = Logic.calculateSortOrder(in: items, at: 2) { _ in nil }
XCTAssertEqual(sortOrder, 2.0, accuracy: 0.01)
}
func test_calculateSortOrder_afterLastItem_returnsLastPlusOne() {
// Last item at 3.0, drop after should get 4.0
let items = buildFlatItems([
.day(1),
.custom("A", sortOrder: 1.0, day: 1),
.custom("B", sortOrder: 3.0, day: 1),
.custom("New", sortOrder: 999, day: 1), // Dropped at end
.day(2)
])
let sortOrder = Logic.calculateSortOrder(in: items, at: 3) { _ in nil }
XCTAssertEqual(sortOrder, 4.0, accuracy: 0.01)
}
func test_calculateSortOrder_beforeFirstItem_returnsHalf() {
// First item at 2.0, drop before should get 1.0 (2.0 / 2)
let items = buildFlatItems([
.day(1),
.custom("New", sortOrder: 999, day: 1), // Dropped first
.custom("A", sortOrder: 2.0, day: 1),
.day(2)
])
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
XCTAssertEqual(sortOrder, 1.0, accuracy: 0.01) // 2.0 / 2 = 1.0
}
func test_calculateSortOrder_manyMidpoints_maintainsPrecision() {
// After many insertions, values should still be distinct
var sortOrders: [Double] = [1.0, 2.0]
for _ in 0..<30 {
let midpoint = (sortOrders[0] + sortOrders[1]) / 2.0
sortOrders.insert(midpoint, at: 1)
}
// All values should be distinct
let uniqueCount = Set(sortOrders).count
XCTAssertEqual(uniqueCount, sortOrders.count, "All sort orders should be unique")
// All should be properly ordered
for i in 0..<(sortOrders.count - 1) {
XCTAssertLessThan(sortOrders[i], sortOrders[i + 1])
}
}
func test_calculateSortOrder_beforeGames_negativeValue() {
// Item dropped before games should get negative sortOrder
let items = buildFlatItems([
.day(1),
.custom("New", sortOrder: 999, day: 1), // Dropped before games
.game("Detroit", day: 1),
.custom("After", sortOrder: 1.0, day: 1),
.day(2)
])
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
XCTAssertLessThan(sortOrder, 0, "Item before games should have negative sortOrder")
}
func test_calculateSortOrder_afterGames_positiveValue() {
// Item dropped after games should get positive sortOrder
let items = buildFlatItems([
.day(1),
.game("Detroit", day: 1),
.custom("New", sortOrder: 999, day: 1), // Dropped after games
.day(2)
])
let sortOrder = Logic.calculateSortOrder(in: items, at: 2) { _ in nil }
XCTAssertGreaterThan(sortOrder, 0, "Item after games should have positive sortOrder")
}
// MARK: - calculateTargetRow Tests
func test_calculateTargetRow_validRow_returnsProposed() {
let validRows = [1, 2, 3, 4, 5]
let result = Logic.calculateTargetRow(proposedRow: 3, validDestinationRows: validRows, sourceRow: 1)
XCTAssertEqual(result, 3)
}
func test_calculateTargetRow_invalidRow_snapsToNearest() {
let validRows = [2, 4, 6]
let result = Logic.calculateTargetRow(proposedRow: 3, validDestinationRows: validRows, sourceRow: 1)
XCTAssertEqual(result, 2) // 3 is closer to 2 than 4
}
func test_calculateTargetRow_row0_clampedTo1() {
let validRows = [1, 2, 3]
let result = Logic.calculateTargetRow(proposedRow: 0, validDestinationRows: validRows, sourceRow: 2)
XCTAssertEqual(result, 1)
}
func test_calculateTargetRow_noValidRows_returnsSource() {
let result = Logic.calculateTargetRow(proposedRow: 3, validDestinationRows: [], sourceRow: 5)
XCTAssertEqual(result, 5)
}
// MARK: - flattenDays Tests
func test_flattenDays_emptyDays_returnsEmpty() {
let result = Logic.flattenDays([], findTravelSortOrder: { _ in nil })
XCTAssertTrue(result.isEmpty)
}
func test_flattenDays_singleEmptyDay_returnsHeaderOnly() {
let days = [
ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil)
]
let result = Logic.flattenDays(days, findTravelSortOrder: { _ in nil })
XCTAssertEqual(result.count, 1)
if case .dayHeader(let day, _) = result[0] {
XCTAssertEqual(day, 1)
} else {
XCTFail("Expected dayHeader")
}
}
func test_flattenDays_dayWithGames_correctOrder() {
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
let days = [
ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil)
]
let result = Logic.flattenDays(days, findTravelSortOrder: { _ in nil })
XCTAssertEqual(result.count, 2)
XCTAssertTrue(result[0].id.starts(with: "day:"))
XCTAssertTrue(result[1].id.starts(with: "games:"))
}
func test_flattenDays_travelBeforeIsIgnored() {
// Semantic model: travelBefore is IGNORED - travel must be in items to appear
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
let days = [
ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil),
ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: travel)
]
let result = Logic.flattenDays(days, findTravelSortOrder: { _ in nil })
// travelBefore is ignored, so only headers appear
// Day 1: header
// Day 2: header (no travel)
XCTAssertEqual(result.count, 2, "travelBefore should be ignored - only headers should appear")
XCTAssertTrue(result[0].id.starts(with: "day:1"))
XCTAssertTrue(result[1].id.starts(with: "day:2"))
}
func test_flattenDays_itemsPartitionedAroundGames() {
// Items with negative sortOrder go before games, positive after
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
let beforeItem = ItineraryItem(tripId: testTripId, day: 1, sortOrder: -1.0, kind: .custom(CustomInfo(title: "Before", icon: "🌅")))
let afterItem = ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .custom(CustomInfo(title: "After", icon: "🌙")))
let days = [
ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: games,
items: [.customItem(beforeItem), .customItem(afterItem)],
travelBefore: nil
)
]
let result = Logic.flattenDays(days, findTravelSortOrder: { _ in nil })
// Expected order: header, beforeItem, games, afterItem
XCTAssertEqual(result.count, 4)
XCTAssertTrue(result[0].id.starts(with: "day:"))
XCTAssertTrue(result[1].id.contains("Before") || result[1].id.starts(with: "item:"))
XCTAssertTrue(result[2].id.starts(with: "games:"))
XCTAssertTrue(result[3].id.contains("After") || result[3].id.starts(with: "item:"))
}
// MARK: - Complex Move Scenarios (Travel Constraints)
/// Scenario: [day3][gameA][day4][travel a->b][day5][day6][gameB]
/// Travel can only move within valid day range based on game constraints
func test_travelMove_constrainedByGames() {
// Setup: Game A on day 3 (departure city), Game B on day 6 (arrival city)
// Travel A->B valid range: days 3-6 (after game A, before game B)
let items = buildFlatItems([
.day(3),
.game("CityA", day: 3),
.day(4),
.travel(from: "CityA", to: "CityB", day: 4),
.day(5),
.day(6),
.game("CityB", day: 6)
])
// Original: [day3(0), gameA(1), day4(2), travel(3), day5(4), day6(5), gameB(6)]
XCTAssertEqual(items.count, 7)
// After removing travel at row 3: [day3, gameA, day4, day5, day6, gameB]
// with day5 at index 3, day6 at index 4
// To insert between day5 and day6 headers, use proposedRow = 4
let moveResult = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: 4)
// Travel should now be between day 5 and day 6 headers (at index 4)
XCTAssertEqual(moveResult.destinationRowInNewArray, 4)
if case .travel = moveResult.items[4] {
// dayNumber scans backward - travel at row 4 will find day5 header at row 3
let day = Logic.dayNumber(in: moveResult.items, forRow: 4)
XCTAssertEqual(day, 5, "Travel should now belong to day 5")
} else {
XCTFail("Expected travel at index 4")
}
}
/// Scenario: Move travel past the arrival game (should be invalid)
func test_travelMove_cannotMovePastArrivalGame() {
// Travel A->B cannot go to day 7 if there's a game at B on day 6
let gameA = H.makeGameItem(city: "CityA", day: 3)
let gameB = H.makeGameItem(city: "CityB", day: 6)
let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 4, sortOrder: 50)
let constraints = ItineraryConstraints(tripDayCount: 7, items: [gameA, gameB])
// Valid position check: day 7 should be invalid
XCTAssertFalse(
constraints.isValidPosition(for: travelItem, day: 7, sortOrder: 50),
"Travel cannot be on day 7 (missed game on day 6)"
)
// Valid position check: day 5 should be valid
XCTAssertTrue(
constraints.isValidPosition(for: travelItem, day: 5, sortOrder: 50),
"Travel on day 5 should be valid"
)
}
/// Scenario: Move travel before the departure game (should be invalid)
func test_travelMove_cannotMoveBeforeDepartureGame() {
// Travel A->B cannot go to day 2 if there's a game at A on day 3
let gameA = H.makeGameItem(city: "CityA", day: 3)
let gameB = H.makeGameItem(city: "CityB", day: 6)
let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 4, sortOrder: 50)
let constraints = ItineraryConstraints(tripDayCount: 7, items: [gameA, gameB])
// Day 2 should be invalid (haven't played game at A yet)
XCTAssertFalse(
constraints.isValidPosition(for: travelItem, day: 2, sortOrder: 50),
"Travel on day 2 is invalid (game at A is on day 3)"
)
// Day 3 AFTER game should be valid
XCTAssertTrue(
constraints.isValidPosition(for: travelItem, day: 3, sortOrder: 150),
"Travel on day 3 after game should be valid"
)
}
// MARK: - Complex Move Scenarios (Custom Items)
/// Scenario: [day3][game][custom][day4][travel][day5][custom2]
/// Moving custom items around
func test_customItem_moveWithinSameDay() {
let items = buildFlatItems([
.day(3),
.game("Detroit", day: 3),
.custom("A", sortOrder: 1.0, day: 3),
.custom("B", sortOrder: 2.0, day: 3),
.day(4)
])
// Move B before A: row 3 -> row 2
let result = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: 2)
// After move: [day3][game][B][A][day4]
XCTAssertEqual(result.destinationRowInNewArray, 2)
// Calculate new sortOrder for B at row 2
let sortOrder = Logic.calculateSortOrder(in: result.items, at: 2) { _ in nil }
XCTAssertLessThan(sortOrder, 1.0, "B moved before A(1.0) should have sortOrder < 1.0")
}
/// Scenario: Move custom item across days
func test_customItem_moveAcrossDays() {
let items = buildFlatItems([
.day(3),
.custom("A", sortOrder: 1.0, day: 3),
.day(4),
.custom("B", sortOrder: 1.0, day: 4),
.day(5)
])
// Move A (row 1) to day 4 (row 3, after B)
let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 3)
let newDay = Logic.dayNumber(in: result.items, forRow: result.destinationRowInNewArray)
XCTAssertEqual(newDay, 4, "A should now be on day 4")
}
/// Scenario: [day3][custom][game][day4][travel][custom2][day5]
func test_customItem_moveBeforeGames_getsNegativeSortOrder() {
let items = buildFlatItems([
.day(3),
.game("Detroit", day: 3),
.custom("A", sortOrder: 1.0, day: 3), // After game
.day(4)
])
// Move A before game: row 2 -> row 1
let result = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: 1)
// After move: [day3][A][game][day4]
let sortOrder = Logic.calculateSortOrder(in: result.items, at: 1) { _ in nil }
XCTAssertLessThan(sortOrder, 0, "Custom item before game should have negative sortOrder")
}
/// Scenario: Multiple items, complex reordering
/// [day3][custom1][custom2][game][day4][travel][day5]
/// Move custom2 to day 5
func test_customItem_moveToEmptyDay() {
let items = buildFlatItems([
.day(3),
.custom("A", sortOrder: -2.0, day: 3),
.custom("B", sortOrder: -1.0, day: 3),
.game("Detroit", day: 3),
.day(4),
.travel(from: "Detroit", to: "Chicago", day: 4),
.day(5)
])
// Move B (row 2) to day 5 (row 6, after day5 header)
let result = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: 6)
let newDay = Logic.dayNumber(in: result.items, forRow: result.destinationRowInNewArray)
XCTAssertEqual(newDay, 5, "B should now be on day 5")
let sortOrder = Logic.calculateSortOrder(in: result.items, at: result.destinationRowInNewArray) { _ in nil }
XCTAssertEqual(sortOrder, 1.0, accuracy: 0.01, "First item on empty day should get 1.0")
}
/// Scenario: Move custom between two existing items on different day
func test_customItem_moveBetweenExistingItems() {
let items = buildFlatItems([
.day(3),
.custom("A", sortOrder: 1.0, day: 3),
.day(4),
.custom("B", sortOrder: 1.0, day: 4),
.custom("C", sortOrder: 3.0, day: 4),
.day(5)
])
// Original: [day3(0), A(1), day4(2), B(3), C(4), day5(5)]
// After removing A at row 1: [day3, day4, B, C, day5] with B at index 2, C at index 3
// To insert between B and C, use proposedRow = 3
let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 3)
let newDay = Logic.dayNumber(in: result.items, forRow: result.destinationRowInNewArray)
XCTAssertEqual(newDay, 4, "A should now be on day 4")
// A should get sortOrder between B(1.0) and C(3.0) = 2.0
let sortOrder = Logic.calculateSortOrder(in: result.items, at: result.destinationRowInNewArray) { _ in nil }
XCTAssertEqual(sortOrder, 2.0, accuracy: 0.01, "A between B(1.0) and C(3.0) should get 2.0")
}
// MARK: - Edge Cases
func test_moveLastItem_toFirstPosition() {
let items = buildFlatItems([
.day(1),
.custom("A", sortOrder: 1.0, day: 1),
.custom("B", sortOrder: 2.0, day: 1),
.custom("C", sortOrder: 3.0, day: 1)
])
// Move C (row 3) to row 1 (before A)
let result = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: 1)
// Order should be: [day1][C][A][B]
XCTAssertEqual(result.destinationRowInNewArray, 1)
let sortOrder = Logic.calculateSortOrder(in: result.items, at: 1) { _ in nil }
XCTAssertLessThan(sortOrder, 1.0, "C moved before A(1.0) should have sortOrder < 1.0")
}
func test_moveFirstItem_toLastPosition() {
let items = buildFlatItems([
.day(1),
.custom("A", sortOrder: 1.0, day: 1),
.custom("B", sortOrder: 2.0, day: 1),
.custom("C", sortOrder: 3.0, day: 1)
])
// Move A (row 1) to row 3 (after C)
let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 3)
// Order should be: [day1][B][C][A]
XCTAssertEqual(result.destinationRowInNewArray, 3)
let sortOrder = Logic.calculateSortOrder(in: result.items, at: 3) { _ in nil }
XCTAssertGreaterThan(sortOrder, 3.0, "A moved after C(3.0) should have sortOrder > 3.0")
}
func test_moveItem_acrossManyDays() {
let items = buildFlatItems([
.day(1),
.custom("A", sortOrder: 1.0, day: 1),
.day(2),
.day(3),
.day(4),
.custom("B", sortOrder: 1.0, day: 4),
.day(5)
])
// Move A (row 1) to day 5 (row 6)
let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 6)
let newDay = Logic.dayNumber(in: result.items, forRow: result.destinationRowInNewArray)
XCTAssertEqual(newDay, 5, "A should now be on day 5")
}
func test_consecutiveMoves_sortOrdersRemainValid() {
// Simulate multiple consecutive moves and verify sortOrders stay ordered
var items = buildFlatItems([
.day(1),
.custom("A", sortOrder: 1.0, day: 1),
.custom("B", sortOrder: 2.0, day: 1),
.custom("C", sortOrder: 3.0, day: 1),
.custom("D", sortOrder: 4.0, day: 1)
])
// Move D to first, then C to first, then B to first
for sourceRow in [4, 4, 4] {
let result = Logic.simulateMove(original: items, sourceRow: sourceRow, destinationProposedRow: 1)
let newSortOrder = Logic.calculateSortOrder(in: result.items, at: 1) { _ in nil }
// Manually update the item's sortOrder (simulating what the app would do)
items = result.items
if case .customItem(var item) = items[1] {
item = ItineraryItem(
id: item.id,
tripId: item.tripId,
day: item.day,
sortOrder: newSortOrder,
kind: item.kind
)
items[1] = .customItem(item)
}
}
// Extract all sortOrders
var sortOrders: [Double] = []
for item in items {
if case .customItem(let customItem) = item {
sortOrders.append(customItem.sortOrder)
}
}
// Verify all are properly ordered (ascending in the array)
for i in 0..<(sortOrders.count - 1) {
XCTAssertLessThan(sortOrders[i], sortOrders[i + 1],
"SortOrders should remain properly ordered after multiple moves")
}
}
// MARK: - DragZones Tests
func test_calculateCustomItemDragZones_headersInvalid() {
let items = buildFlatItems([
.day(1),
.custom("A", sortOrder: 1.0, day: 1),
.day(2),
.custom("B", sortOrder: 1.0, day: 2),
.day(3)
])
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A")
let zones = Logic.calculateCustomItemDragZones(item: customItem, flatItems: items)
// Headers at rows 0, 2, 4 should be invalid
XCTAssertTrue(zones.invalidRowIndices.contains(0))
XCTAssertTrue(zones.invalidRowIndices.contains(2))
XCTAssertTrue(zones.invalidRowIndices.contains(4))
// Items at rows 1, 3 should be valid
XCTAssertTrue(zones.validDropRows.contains(1))
XCTAssertTrue(zones.validDropRows.contains(3))
}
func test_calculateTravelDragZones_respectsDayRange() {
let items = buildFlatItems([
.day(1),
.game("CityA", day: 1),
.day(2),
.travel(from: "CityA", to: "CityB", day: 2),
.day(3),
.game("CityB", day: 3)
])
let segment = H.makeTravelSegment(from: "CityA", to: "CityB")
let travelValidRanges = ["travel:0:citya->cityb": 1...3]
let zones = Logic.calculateTravelDragZones(
segment: segment,
flatItems: items,
travelValidRanges: travelValidRanges,
constraints: nil,
findTravelItem: { _ in nil }
)
// All days 1-3 should be valid (6 rows total)
XCTAssertEqual(zones.validDropRows.count, 6)
XCTAssertTrue(zones.invalidRowIndices.isEmpty)
}
}