// // 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 @MainActor 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 // 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, sourceRow: 1, flatItems: items, constraints: nil, findTravelSortOrder: { _ in nil } ) // New API excludes source row from both valid and invalid sets. XCTAssertEqual(zones.invalidRowIndices, Set([0])) XCTAssertEqual(Set(zones.validDropRows), Set([2, 3, 4, 5])) XCTAssertFalse(zones.validDropRows.contains(1)) } 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 travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 2, sortOrder: 1.0) let zones = Logic.calculateTravelDragZones( segment: segment, sourceRow: 3, flatItems: items, travelValidRanges: travelValidRanges, constraints: nil, findTravelItem: { _ in travelItem }, makeTravelItem: { _ in travelItem }, findTravelSortOrder: { _ in travelItem.sortOrder } ) XCTAssertEqual(Set(zones.validDropRows), Set([1, 2, 4, 5, 6])) XCTAssertEqual(zones.invalidRowIndices, Set([0])) XCTAssertFalse(zones.validDropRows.contains(3)) } }