// // ItinerarySemanticTravelTests.swift // SportsTimeTests // // Acceptance tests for semantic travel model in ItineraryReorderingLogic. // These tests verify the core invariants of the refactored logic. // import XCTest @testable import SportsTime private typealias H = ItineraryTestHelpers private typealias Logic = ItineraryReorderingLogic final class ItinerarySemanticTravelTests: XCTestCase { private let testDate = H.testDate private let testTripId = H.testTripId // MARK: - Acceptance Test A: No Travel Duplication /// flattenDays must NOT duplicate travel that appears in both travelBefore AND items. /// Under semantic model, travelBefore is IGNORED entirely. func test_A_flattenDays_ignoresTravelBefore_noDuplication() { // Create a travel segment let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago") // Create day with travel in BOTH travelBefore (legacy) AND items (semantic) let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2) let days = [ ItineraryDayData( id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil ), ItineraryDayData( id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [travelItem], // Travel in items (semantic model) travelBefore: travel // Travel also in travelBefore (legacy) ) ] // Provide sortOrder lookup for the travel let result = Logic.flattenDays(days) { segment in if segment.fromLocation.name == "Detroit" && segment.toLocation.name == "Chicago" { return 1.0 // sortOrder >= 0 means after games } return nil } // Count travel rows let travelCount = result.filter { row in if case .travel = row { return true } return false }.count XCTAssertEqual(travelCount, 1, "Travel should appear exactly ONCE (travelBefore is ignored)") } /// When travelBefore exists but items is empty, travel should NOT appear at all. func test_A_flattenDays_travelBeforeOnly_notIncluded() { 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: [], // No travel in items travelBefore: travel // Only in travelBefore (legacy) ) ] let result = Logic.flattenDays(days) { _ in 1.0 } let travelCount = result.filter { row in if case .travel = row { return true } return false }.count XCTAssertEqual(travelCount, 0, "travelBefore should be completely ignored - no travel should appear") } // MARK: - Acceptance Test B: dayForTravelAt Uses Backward Scan /// dayForTravelAt must use backward scan (same as dayNumber), not forward scan. func test_B_dayForTravelAt_usesBackwardScan_matchesDayNumber() { // Build items: [day1, game1, travel, day2, game2] // Travel at row 2 should return day 1 (backward scan finds day1 header) let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago") let game1 = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate) let game2 = H.makeRichGame(city: "Chicago", hour: 19, baseDate: H.dayAfter(testDate)) let items: [ItineraryRowItem] = [ .dayHeader(dayNumber: 1, date: testDate), .games([game1], dayNumber: 1), .travel(travel, dayNumber: 2), // This is what legacy would say .dayHeader(dayNumber: 2, date: H.dayAfter(testDate)), .games([game2], dayNumber: 2) ] let travelRow = 2 // Both functions should return the SAME value let dayNumberResult = Logic.dayNumber(in: items, forRow: travelRow) let dayForTravelResult = Logic.dayForTravelAt(row: travelRow, in: items) XCTAssertEqual(dayNumberResult, dayForTravelResult, "dayForTravelAt must match dayNumber (backward scan)") XCTAssertEqual(dayForTravelResult, 1, "Travel at row 2 should belong to day 1 (backward scan finds day1 header)") } /// Travel placed after day header (but no next header) uses backward scan. func test_B_dayForTravelAt_atEndOfList_usesBackwardScan() { let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago") let items: [ItineraryRowItem] = [ .dayHeader(dayNumber: 1, date: testDate), .dayHeader(dayNumber: 2, date: H.dayAfter(testDate)), .travel(travel, dayNumber: 2) // At end of list ] let result = Logic.dayForTravelAt(row: 2, in: items) let dayNumberResult = Logic.dayNumber(in: items, forRow: 2) XCTAssertEqual(result, dayNumberResult) XCTAssertEqual(result, 2, "Travel at row 2 after day2 header should belong to day 2") } // MARK: - Acceptance Test C: Custom Item Valid Destinations Include "Above Games" /// Custom items can be placed above games (with negative sortOrder). func test_C_customItemValidDestinations_includesAboveGames() { let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate) let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity") // Row layout: [0: header, 1: games, 2: customItem] let items: [ItineraryRowItem] = [ .dayHeader(dayNumber: 1, date: testDate), // Row 0 .games([game], dayNumber: 1), // Row 1 .customItem(customItem) // Row 2 ] // Create constraints that allow custom items anywhere let constraints = ItineraryConstraints(tripDayCount: 1, items: []) let validRows = Logic.computeValidDestinationRowsProposed( flatItems: items, sourceRow: 2, // Moving customItem from row 2 dragged: .customItem(customItem), travelValidRanges: [:], constraints: constraints, findTravelItem: { _ in nil }, makeTravelItem: { segment in let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name) return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 0, kind: .travel(info)) }, findTravelSortOrder: { _ in nil } ) // After removing customItem at row 2, array is: [header, games] with count 2 // Valid proposed rows are 1 (after header) and 2 (after games, but beyond count-1) // So proposedRow 1 should put item between header and games XCTAssertTrue(validRows.contains(1), "proposedRow 1 (insert between header and games) should be valid") } /// Verify sortOrder is negative when custom item dropped above games. func test_C_customItemAboveGames_getsNegativeSortOrder() { let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate) let customItem = H.makeCustomItem(day: 1, sortOrder: 5.0, title: "Activity") let items: [ItineraryRowItem] = [ .dayHeader(dayNumber: 1, date: testDate), .customItem(customItem), // Now at row 1 (moved above games) .games([game], dayNumber: 1) // Row 2 ] let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil } XCTAssertLessThan(sortOrder, 0, "Custom item above games must have negative sortOrder") } // MARK: - Acceptance Test D: Travel Edge-Day Respects SortOrder /// Travel on departure game day is valid only if sortOrder > game's sortOrder. func test_D_travelOnDepartureGameDay_validOnlyAfterGame() { // Game at city A on day 3 let gameA = H.makeGameItem(city: "CityA", day: 3) let gameB = H.makeGameItem(city: "CityB", day: 6) let constraints = ItineraryConstraints(tripDayCount: 7, items: [gameA, gameB]) let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 3, sortOrder: 50) // Travel on day 3 BEFORE game (sortOrder = -50) should be INVALID XCTAssertFalse( constraints.isValidPosition(for: travelItem, day: 3, sortOrder: -50), "Travel before departure game should be invalid" ) // Travel on day 3 AFTER game (sortOrder = 150) should be VALID XCTAssertTrue( constraints.isValidPosition(for: travelItem, day: 3, sortOrder: 150), "Travel after departure game should be valid" ) } /// Travel on arrival game day is valid only if sortOrder < game's sortOrder. func test_D_travelOnArrivalGameDay_validOnlyBeforeGame() { let gameA = H.makeGameItem(city: "CityA", day: 3) let gameB = H.makeGameItem(city: "CityB", day: 6) let constraints = ItineraryConstraints(tripDayCount: 7, items: [gameA, gameB]) let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 6, sortOrder: 50) // Travel on day 6 AFTER game (sortOrder = 150) should be INVALID XCTAssertFalse( constraints.isValidPosition(for: travelItem, day: 6, sortOrder: 150), "Travel after arrival game should be invalid" ) // Travel on day 6 BEFORE game (sortOrder = -50) should be VALID XCTAssertTrue( constraints.isValidPosition(for: travelItem, day: 6, sortOrder: -50), "Travel before arrival game should be valid" ) } // MARK: - Acceptance Test E: computeValidDestinationRowsProposed Matches Constraints /// For each proposedRow, simulate → compute (day, sortOrder) → constraints.isValidPosition must match. func test_E_computeValidDestinationRows_matchesConstraintsValidation() { let gameA = H.makeRichGame(city: "CityA", hour: 19, baseDate: testDate) let gameBDate = Calendar.current.date(byAdding: .day, value: 3, to: testDate)! let gameB = H.makeRichGame(city: "CityB", hour: 19, baseDate: gameBDate) let travel = H.makeTravelSegment(from: "CityA", to: "CityB") let day2Date = Calendar.current.date(byAdding: .day, value: 1, to: testDate)! let day3Date = Calendar.current.date(byAdding: .day, value: 2, to: testDate)! let day4Date = Calendar.current.date(byAdding: .day, value: 3, to: testDate)! let items: [ItineraryRowItem] = [ .dayHeader(dayNumber: 1, date: testDate), .games([gameA], dayNumber: 1), .dayHeader(dayNumber: 2, date: day2Date), .travel(travel, dayNumber: 2), // Source row 3 .dayHeader(dayNumber: 3, date: day3Date), .dayHeader(dayNumber: 4, date: day4Date), .games([gameB], dayNumber: 4) ] let gameItemA = H.makeGameItem(city: "CityA", day: 1) let gameItemB = H.makeGameItem(city: "CityB", day: 4) let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 2, sortOrder: 1.0) let constraints = ItineraryConstraints(tripDayCount: 4, items: [gameItemA, gameItemB]) let travelValidRanges = ["travel:citya->cityb": 1...4] let validRows = Logic.computeValidDestinationRowsProposed( flatItems: items, sourceRow: 3, dragged: .travel(travel, dayNumber: 2), travelValidRanges: travelValidRanges, constraints: constraints, findTravelItem: { _ in travelItem }, makeTravelItem: { _ in travelItem }, findTravelSortOrder: { _ in 1.0 } ) // Manually verify each row for proposedRow in 1..= sourceRow. func test_proposedToOriginal_atOrAboveSource_addOne() { // Source at row 5, proposed is 5 // After removing row 5, proposed 5 corresponds to original 6 let result = Logic.proposedToOriginal(5, sourceRow: 5) XCTAssertEqual(result, 6) // Source at row 5, proposed is 7 let result2 = Logic.proposedToOriginal(7, sourceRow: 5) XCTAssertEqual(result2, 8) } /// originalToProposed converts correctly. func test_originalToProposed_convertsCorrectly() { // Original 3 with source at 5 -> proposed 3 (below source) XCTAssertEqual(Logic.originalToProposed(3, sourceRow: 5), 3) // Original 7 with source at 5 -> proposed 6 (above source) XCTAssertEqual(Logic.originalToProposed(7, sourceRow: 5), 6) // Original 5 with source at 5 -> nil (is the source) XCTAssertNil(Logic.originalToProposed(5, sourceRow: 5)) } // MARK: - Audit Fix Tests: DragZones Coordinate Space /// DragZones validDropRows should be in ORIGINAL coordinate space. func test_dragZones_returnsOriginalCoordinates() { let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate) let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity") // [0: header, 1: games, 2: customItem] let items: [ItineraryRowItem] = [ .dayHeader(dayNumber: 1, date: testDate), .games([game], dayNumber: 1), .customItem(customItem) ] let constraints = ItineraryConstraints(tripDayCount: 1, items: []) let zones = Logic.calculateCustomItemDragZones( item: customItem, sourceRow: 2, flatItems: items, constraints: constraints, findTravelSortOrder: { _ in nil } ) // Source row (2) should NOT be in invalidRowIndices (it's being dragged) XCTAssertFalse(zones.invalidRowIndices.contains(2), "Source row should not be in invalidRowIndices") // Row 0 (header) should be invalid XCTAssertTrue(zones.invalidRowIndices.contains(0), "Day header should be invalid") // validDropRows are in ORIGINAL coordinate space // After removing row 2, post-removal array is [header, games] with count 2 // Proposed indices: 0 (before header - invalid), 1 (after header/games), 2 (append at end) // - Proposed 1 -> original 1 (below source 2) // - Proposed 2 -> original 3 (at/above source, so +1) // Note: original index 3 is valid for append operations (insert at end) for validRow in zones.validDropRows { // Valid rows must be <= items.count (allowing append at end which is items.count) XCTAssertLessThanOrEqual(validRow, items.count, "Valid drop rows must be valid indices for insertion (including append)") // Source row should not be included XCTAssertNotEqual(validRow, 2, "Valid drop rows should not include source row itself") } } /// DragZones invalidRowIndices should not include sourceRow. func test_dragZones_excludesSourceRowFromInvalid() { let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago") let items: [ItineraryRowItem] = [ .dayHeader(dayNumber: 1, date: testDate), .travel(travel, dayNumber: 1), // Source row 1 .dayHeader(dayNumber: 2, date: H.dayAfter(testDate)) ] let zones = Logic.calculateTravelDragZones( segment: travel, sourceRow: 1, flatItems: items, travelValidRanges: [:], constraints: nil, findTravelItem: { _ in nil }, makeTravelItem: { segment in let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name) return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .travel(info)) }, findTravelSortOrder: { _ in 1.0 } ) XCTAssertFalse(zones.invalidRowIndices.contains(1), "Source row (being dragged) should not be marked invalid") } // MARK: - Audit Fix Tests: computeValidDestinationRowsProposed End Position /// Verify that append-at-end is handled correctly. func test_computeValidDestinationRows_includesAppendAtEnd() { let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity") // [0: header, 1: customItem] let items: [ItineraryRowItem] = [ .dayHeader(dayNumber: 1, date: testDate), .customItem(customItem) ] let constraints = ItineraryConstraints(tripDayCount: 1, items: []) let validRows = Logic.computeValidDestinationRowsProposed( flatItems: items, sourceRow: 1, dragged: .customItem(customItem), travelValidRanges: [:], constraints: constraints, findTravelItem: { _ in nil }, makeTravelItem: { _ in fatalError() }, findTravelSortOrder: { _ in nil } ) // After removing row 1, array is [header] with count 1 // Valid proposed positions: 1 (after header, which is append-at-end) // maxProposed = items.count - 1 = 1 // So we test 1...1 which includes the append position // The proposed row 1 should be valid (append after header) // But wait - we need to check what simulateMove does: // simulateMove with count=1 (after removal), proposed=1 -> clampedDest=1 which is insert at end // This inserts AFTER the header, which is valid XCTAssertTrue(validRows.contains(1), "Proposed row 1 (append after header) should be valid") } }