Files
Sportstime/SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift
Trey t ff6f4b6c2c fix: resolve travel anchor ID collision for repeat city pairs
Include segment index in travel anchor IDs ("travel:INDEX:from->to")
so Follow Team trips visiting the same city pair multiple times get
unique, independently addressable travel segments. Prevents override
dictionary collisions and incorrect validDayRange lookups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:57:53 -06:00

683 lines
28 KiB
Swift

//
// 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:0: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..<items.count {
let simulated = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: proposedRow)
guard simulated.didMove else { continue }
let destRow = simulated.destinationRowInNewArray
// Skip day headers
if case .dayHeader = simulated.items[destRow] {
XCTAssertFalse(validRows.contains(proposedRow),
"Day header row \(proposedRow) should not be valid")
continue
}
let day = Logic.dayNumber(in: simulated.items, forRow: destRow)
let sortOrder = Logic.calculateSortOrder(in: simulated.items, at: destRow) { _ in 1.0 }
let constraintSaysValid = constraints.isValidPosition(for: travelItem, day: day, sortOrder: sortOrder)
let functionSaysValid = validRows.contains(proposedRow)
XCTAssertEqual(constraintSaysValid, functionSaysValid,
"Row \(proposedRow) → day \(day), sortOrder \(sortOrder): " +
"constraint says \(constraintSaysValid), function says \(functionSaysValid)")
}
}
/// Custom item should also match constraints validation.
func test_E_customItemValidDestinations_matchesConstraints() {
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
let customItem = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Lunch")
let day2Date = Calendar.current.date(byAdding: .day, value: 1, to: testDate)!
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate),
.games([game], dayNumber: 1),
.customItem(customItem), // Source row 2
.dayHeader(dayNumber: 2, date: day2Date)
]
let constraints = ItineraryConstraints(tripDayCount: 2, items: [])
let validRows = Logic.computeValidDestinationRowsProposed(
flatItems: items,
sourceRow: 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 }
)
// Verify each row matches constraint
for proposedRow in 1..<items.count {
let simulated = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: proposedRow)
guard simulated.didMove else { continue }
let destRow = simulated.destinationRowInNewArray
// Skip day headers
if case .dayHeader = simulated.items[destRow] {
XCTAssertFalse(validRows.contains(proposedRow),
"Day header row \(proposedRow) should not be valid")
continue
}
let day = Logic.dayNumber(in: simulated.items, forRow: destRow)
let sortOrder = Logic.calculateSortOrder(in: simulated.items, at: destRow) { _ in nil }
let testItem = ItineraryItem(
id: customItem.id,
tripId: customItem.tripId,
day: day,
sortOrder: sortOrder,
kind: customItem.kind
)
let constraintSaysValid = constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder)
let functionSaysValid = validRows.contains(proposedRow)
XCTAssertEqual(constraintSaysValid, functionSaysValid,
"Custom item row \(proposedRow) → day \(day), sortOrder \(sortOrder): mismatch")
}
}
// MARK: - SimulateMove Bounds Safety
func test_simulateMove_negativeSourceRow_returnsOriginal() {
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate)
]
let result = Logic.simulateMove(original: items, sourceRow: -1, destinationProposedRow: 0)
XCTAssertFalse(result.didMove, "Invalid sourceRow should not move")
XCTAssertEqual(result.items.count, 1)
}
func test_simulateMove_sourceRowOutOfBounds_returnsOriginal() {
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate)
]
let result = Logic.simulateMove(original: items, sourceRow: 5, destinationProposedRow: 0)
XCTAssertFalse(result.didMove, "Out of bounds sourceRow should not move")
XCTAssertEqual(result.items.count, 1)
}
func test_simulateMove_validSourceRow_didMoveIsTrue() {
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate),
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
]
let result = Logic.simulateMove(original: items, sourceRow: 0, destinationProposedRow: 1)
XCTAssertTrue(result.didMove, "Valid sourceRow should move")
}
// MARK: - Audit Fix Tests: Travel Never Disappears
/// Travel in items must appear even if findTravelSortOrder returns nil.
func test_flattenDays_travelAppearsEvenWithNilSortOrder() {
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
let travelItem = ItineraryRowItem.travel(travel, dayNumber: 1)
let days = [
ItineraryDayData(
id: 1,
dayNumber: 1,
date: testDate,
games: [],
items: [travelItem],
travelBefore: nil
)
]
// Return nil for sortOrder lookup - should use default (1.0)
let result = Logic.flattenDays(days) { _ in nil }
let travelCount = result.filter { row in
if case .travel = row { return true }
return false
}.count
XCTAssertEqual(travelCount, 1, "Travel must appear even when sortOrder lookup returns nil")
}
// MARK: - Audit Fix Tests: travelRow Semantic Lookup
/// travelRow must find travel by scanning day section, not by embedded dayNumber.
func test_travelRow_usesSemanticDayLookup() {
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
// Travel has embedded dayNumber 99 (wrong), but is positioned after day 2 header
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate),
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate)),
.travel(travel, dayNumber: 99) // Wrong embedded dayNumber
]
// Semantic lookup should find travel in day 2 section
let result = Logic.travelRow(in: items, forDay: 2)
XCTAssertEqual(result, 2, "travelRow should find travel in day 2 section by position, not embedded dayNumber")
}
/// travelRow returns nil if no travel in that day section.
func test_travelRow_returnsNilIfNoTravelInDaySection() {
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate),
.travel(travel, dayNumber: 1), // Travel in day 1
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
]
// Day 2 has no travel
let result = Logic.travelRow(in: items, forDay: 2)
XCTAssertNil(result, "travelRow should return nil when no travel in day section")
}
// MARK: - Audit Fix Tests: calculateSortOrder Region Correctness
/// Before-games region must always return negative sortOrder.
func test_calculateSortOrder_beforeGamesRegion_alwaysNegative() {
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Morning")
// Item placed BEFORE games row
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate), // Row 0
.customItem(customItem), // Row 1 - before games
.games([game], dayNumber: 1) // Row 2
]
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
XCTAssertLessThan(sortOrder, 0, "Item before games must have negative sortOrder")
}
/// After-games region must always return non-negative sortOrder.
func test_calculateSortOrder_afterGamesRegion_alwaysNonNegative() {
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Evening")
// Item placed AFTER games row
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate),
.games([game], dayNumber: 1),
.customItem(customItem) // Row 2 - after games
]
let sortOrder = Logic.calculateSortOrder(in: items, at: 2) { _ in nil }
XCTAssertGreaterThanOrEqual(sortOrder, 0, "Item after games must have non-negative sortOrder")
}
/// First item in before-games region gets proper negative sortOrder.
func test_calculateSortOrder_firstItemBeforeGames_getsNegativeValue() {
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
// Create a custom item to place before games (simulating a moved item)
let customItem = H.makeCustomItem(day: 1, sortOrder: -1.0, title: "Pre-game activity")
// This represents the state AFTER moving an item to row 1 (between header and games)
// calculateSortOrder expects the moved item already in the array
let items: [ItineraryRowItem] = [
.dayHeader(dayNumber: 1, date: testDate), // Row 0
.customItem(customItem), // Row 1 - the moved item (before games)
.games([game], dayNumber: 1) // Row 2
]
// Calculate sortOrder for the item at row 1
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
// With no other movable items in before-games region, should get -1.0
XCTAssertLessThan(sortOrder, 0, "First item before games should get negative sortOrder")
XCTAssertEqual(sortOrder, -1.0, "With no neighbors in before-games region, should return -1.0")
}
// MARK: - Audit Fix Tests: Coordinate Space Conversion
/// proposedToOriginal converts correctly when proposed < sourceRow.
func test_proposedToOriginal_belowSource_unchanged() {
// Source at row 5, proposed is 3
// After removing row 5, proposed 3 is still original 3
let result = Logic.proposedToOriginal(3, sourceRow: 5)
XCTAssertEqual(result, 3)
}
/// proposedToOriginal converts correctly when proposed >= 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")
}
}