From 72447c61fe589a7c431d4d6b9d004f6e42b237d1 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 18 Jan 2026 20:04:52 -0600 Subject: [PATCH] refactor(itinerary): extract reordering logic into pure functions Extract all itinerary reordering logic from ItineraryTableViewController into ItineraryReorderingLogic.swift for testability. Key changes: - Add flattenDays, dayNumber, travelRow, simulateMove pure functions - Add calculateSortOrder with proper region classification (before/after games) - Add computeValidDestinationRowsProposed with simulation+validation pattern - Add coordinate space conversion helpers (proposedToOriginal, originalToProposed) - Fix DragZones coordinate space mismatch (was mixing proposed/original indices) - Add comprehensive documentation of coordinate space conventions Test coverage includes: - Row flattening order and semantic travel model - Sort order calculation for before/after games regions - Travel constraints validation - DragZones coordinate space correctness - Coordinate conversion helpers - Edge cases (empty days, multi-day trips) Co-Authored-By: Claude Opus 4.5 --- .../Trip/Views/ItineraryReorderingLogic.swift | 921 ++++++++++++++++++ .../Views/ItineraryTableViewController.swift | 642 ++---------- .../Trip/ItineraryCustomItemTests.swift | 153 +++ .../Trip/ItineraryEdgeCaseTests.swift | 67 ++ .../Trip/ItineraryReorderingLogicTests.swift | 847 ++++++++++++++++ .../Trip/ItineraryReorderingTests.swift | 183 ++++ .../Trip/ItineraryRowFlatteningTests.swift | 191 ++++ .../Trip/ItinerarySemanticTravelTests.swift | 682 +++++++++++++ .../Trip/ItinerarySortOrderTests.swift | 254 +++++ .../Features/Trip/ItineraryTestHelpers.swift | 125 +++ .../Trip/ItineraryTravelConstraintTests.swift | 287 ++++++ 11 files changed, 3795 insertions(+), 557 deletions(-) create mode 100644 SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift create mode 100644 SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift create mode 100644 SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift create mode 100644 SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift create mode 100644 SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift create mode 100644 SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift create mode 100644 SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift create mode 100644 SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift create mode 100644 SportsTimeTests/Features/Trip/ItineraryTestHelpers.swift create mode 100644 SportsTimeTests/Features/Trip/ItineraryTravelConstraintTests.swift diff --git a/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift b/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift new file mode 100644 index 0000000..a271daa --- /dev/null +++ b/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift @@ -0,0 +1,921 @@ +// +// ItineraryReorderingLogic.swift +// SportsTime +// +// Pure functions for itinerary reordering logic. +// Extracted from ItineraryTableViewController for testability. +// +// All functions in this enum are pure - they take inputs and return outputs +// with no side effects, making them fully unit-testable without UIKit. +// +// SEMANTIC TRAVEL MODEL: +// - Travel items are positioned semantically via (day, sortOrder), not structurally. +// - Travel can appear before games (sortOrder < 0) or after games (sortOrder >= 0). +// - The legacy `travelBefore` field on ItineraryDayData is IGNORED by flattenDays. +// - All movable items (custom + travel) use the same day computation: backward scan to nearest dayHeader. +// +// COORDINATE SPACE CONVENTIONS: +// +// Two coordinate spaces exist during drag-drop operations: +// +// 1. ORIGINAL SPACE (flatItems indices) +// - Row indices in the current flatItems array: 0..= sourceRow: return proposed + 1 (shift up past removed source) +// • If proposed < sourceRow: return proposed (unchanged) +// - originalToProposed(original, sourceRow): Converts original → proposed +// • If original == sourceRow: return nil (source has no proposed equivalent) +// • If original > sourceRow: return original - 1 (shift down) +// • If original < sourceRow: return original (unchanged) +// +// WHY THIS MATTERS: +// - DragZones are used for UI highlighting (which cells to dim/enable) +// - UI highlighting operates on the visible table, which uses ORIGINAL indices +// - But validation uses simulation, which operates in PROPOSED space +// - Getting this wrong causes visual bugs (wrong rows highlighted) or logic bugs +// + +import Foundation + +// MARK: - Pure Functions for Itinerary Reordering + +/// Container for all pure reordering logic. +/// Using an enum (no cases) as a namespace for static functions. +enum ItineraryReorderingLogic { + + // MARK: - Row Flattening + + /// Default sortOrder for travel when lookup returns nil. + /// Travel defaults to after-games region (positive value). + private static let defaultTravelSortOrder: Double = 1.0 + + /// Flattens hierarchical day data into a single array of row items. + /// + /// **SEMANTIC MODEL**: This function ignores `day.travelBefore` entirely. + /// Travel segments must be included in `day.items` with appropriate sortOrder. + /// + /// For each day, rows are added in this order: + /// 1. Day header - "Day N · Date" + /// 2. Items with sortOrder < 0 (before games), sorted by sortOrder ascending + /// 3. Games - all games for this day (grouped as one row) + /// 4. Items with sortOrder >= 0 (after games), sorted by sortOrder ascending + /// + /// - Parameters: + /// - days: Array of ItineraryDayData from the wrapper + /// - findTravelSortOrder: Closure to look up sortOrder for a travel segment + /// - Returns: Flattened array of ItineraryRowItem + static func flattenDays( + _ days: [ItineraryDayData], + findTravelSortOrder: (TravelSegment) -> Double? + ) -> [ItineraryRowItem] { + var flatItems: [ItineraryRowItem] = [] + + for day in days { + // NOTE: day.travelBefore is IGNORED under semantic travel model. + // Travel must be in day.items with a sortOrder to appear. + + // 1. Day header (structural anchor) + flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) + + // 2. Partition movable items around games boundary + // Tuple includes tiebreaker for stable sorting when sortOrders are equal + var beforeGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = [] + var afterGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = [] + var insertionOrder = 0 + + for row in day.items { + let sortOrder: Double + let tiebreaker = insertionOrder + insertionOrder += 1 + + switch row { + case .customItem(let item): + sortOrder = item.sortOrder + + case .travel(let segment, _): + if let so = findTravelSortOrder(segment) { + sortOrder = so + } else { + // Travel without stored sortOrder gets a safe default. + // Log a warning in debug builds - this shouldn't happen in production. + #if DEBUG + print("⚠️ flattenDays: Travel segment missing sortOrder: \(segment.fromLocation.name) → \(segment.toLocation.name). Using default: \(defaultTravelSortOrder)") + #endif + sortOrder = defaultTravelSortOrder + } + + case .games, .dayHeader: + // These item types are not movable and handled separately. + // Skip explicitly - games are added after partitioning. + continue + } + + if sortOrder < 0 { + beforeGames.append((sortOrder, tiebreaker, row)) + } else { + afterGames.append((sortOrder, tiebreaker, row)) + } + } + + // Sort by sortOrder within each region, with stable tiebreaker + beforeGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) } + afterGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) } + + flatItems.append(contentsOf: beforeGames.map { $0.item }) + + // 3. Games for this day (bundled as one row) + if !day.games.isEmpty { + flatItems.append(.games(day.games, dayNumber: day.dayNumber)) + } + + // 4. Items after games + flatItems.append(contentsOf: afterGames.map { $0.item }) + } + + return flatItems + } + + // MARK: - Day Number Lookup + + /// Finds which day a row at the given index belongs to. + /// + /// Scans backwards from the row to find a `.dayHeader`. + /// Returns 1 as fallback if no header is found. + /// + /// - Parameters: + /// - items: The flat array of row items + /// - row: The row index to look up + /// - Returns: The day number (1-indexed) + static func dayNumber(in items: [ItineraryRowItem], forRow row: Int) -> Int { + guard !items.isEmpty else { return 1 } + let clamped = min(max(0, row), items.count - 1) + for i in stride(from: clamped, through: 0, by: -1) { + if case .dayHeader(let dayNum, _) = items[i] { + return dayNum + } + } + return 1 + } + + /// Finds the row index of the day header for a specific day number. + /// + /// - Parameters: + /// - items: The flat array of row items + /// - day: The day number to find + /// - Returns: The row index, or nil if not found + static func dayHeaderRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? { + for (index, item) in items.enumerated() { + if case .dayHeader(let dayNum, _) = item, dayNum == day { + return index + } + } + return nil + } + + /// Finds the row index of the travel segment on a specific day. + /// + /// **SEMANTIC MODEL**: Does NOT use the embedded dayNumber in .travel(). + /// Instead, scans the day section (between dayHeader(day) and dayHeader(day+1)) + /// and returns the first travel row found. + /// + /// - Parameters: + /// - items: The flat array of row items + /// - day: The day number to find + /// - Returns: The row index, or nil if no travel on that day + static func travelRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? { + // Find the day header row + guard let headerRow = dayHeaderRow(in: items, forDay: day) else { + return nil + } + + // Scan forward until next day header, looking for travel + for i in (headerRow + 1).. Int? { + for (index, item) in items.enumerated() { + if case .travel(_, let dayNum) = item, dayNum == day { + return index + } + } + return nil + } + + /// Determines which day a travel segment belongs to at a given row position. + /// + /// **SEMANTIC MODEL**: Uses backward scan to find the nearest preceding dayHeader. + /// This is consistent with how all movable items determine their day. + /// + /// - Parameters: + /// - row: The row index of the travel + /// - items: The flat array of row items + /// - Returns: The day number the travel belongs to + static func dayForTravelAt(row: Int, in items: [ItineraryRowItem]) -> Int { + // Semantic model: scan backward to find the day this item belongs to + // (same logic as dayNumber) + return dayNumber(in: items, forRow: row) + } + + // MARK: - Move Simulation + + /// Result of simulating a move operation. + struct SimulatedMove { + let items: [ItineraryRowItem] + let destinationRowInNewArray: Int + let didMove: Bool // false if move was invalid/no-op + } + + /// Simulates UITableView move semantics with bounds safety. + /// + /// UITableView moves work as: remove at sourceRow from ORIGINAL array, + /// then insert at destinationProposedRow in the NEW array (post-removal coordinate space). + /// + /// - Parameters: + /// - original: The original flat items array + /// - sourceRow: Where the item is being moved from + /// - destinationProposedRow: Where it's being moved to (in post-removal space) + /// - Returns: The new array, the actual destination row, and whether the move occurred + static func simulateMove( + original: [ItineraryRowItem], + sourceRow: Int, + destinationProposedRow: Int + ) -> SimulatedMove { + // Bounds safety: return original unchanged if sourceRow is invalid + guard sourceRow >= 0 && sourceRow < original.count else { + return SimulatedMove(items: original, destinationRowInNewArray: sourceRow, didMove: false) + } + + var items = original + let moving = items.remove(at: sourceRow) + let clampedDest = min(max(0, destinationProposedRow), items.count) + items.insert(moving, at: clampedDest) + return SimulatedMove(items: items, destinationRowInNewArray: clampedDest, didMove: true) + } + + // MARK: - Coordinate Space Conversion + + /// Converts a proposed destination index to the equivalent original index. + /// + /// UITableView move semantics: remove at sourceRow first, then insert at proposed position. + /// This means proposed indices >= sourceRow map to original indices + 1. + /// + /// - Parameters: + /// - proposed: Index in post-removal coordinate space + /// - sourceRow: The row being moved (in original space) + /// - Returns: Equivalent index in original coordinate space + static func proposedToOriginal(_ proposed: Int, sourceRow: Int) -> Int { + if proposed >= sourceRow { + return proposed + 1 + } else { + return proposed + } + } + + /// Converts an original index to the equivalent proposed destination index. + /// + /// - Parameters: + /// - original: Index in original coordinate space + /// - sourceRow: The row being moved (in original space) + /// - Returns: Equivalent index in post-removal coordinate space, or nil if original == sourceRow + static func originalToProposed(_ original: Int, sourceRow: Int) -> Int? { + if original == sourceRow { + // The dragged item itself has no proposed equivalent + return nil + } else if original > sourceRow { + return original - 1 + } else { + return original + } + } + + // MARK: - Sort Order Calculation + + /// Calculates the sortOrder for an item dropped at the given row position. + /// + /// Uses **midpoint insertion** algorithm to avoid renumbering existing items: + /// - Between items A(1.0) and B(2.0): new sortOrder = 1.5 + /// - First item in empty day: sortOrder = 1.0 + /// - After last item: sortOrder = last + 1.0 + /// - Before first item: sortOrder = first / 2.0 + /// + /// **Region classification**: + /// - `row < gamesRow` => before-games region => sortOrder < 0 + /// - `row > gamesRow` => after-games region => sortOrder >= 0 + /// - `row == gamesRow` => treated as after-games (cannot drop ON games row) + /// - No games on day => after-games region (sortOrder >= 0) + /// + /// - Parameters: + /// - items: The flat array of row items (with moved item already in place) + /// - row: The row index where the item was dropped + /// - findTravelSortOrder: Closure to look up sortOrder for travel segments + /// - Returns: The calculated sortOrder + static func calculateSortOrder( + in items: [ItineraryRowItem], + at row: Int, + findTravelSortOrder: (TravelSegment) -> Double? + ) -> Double { + let day = dayNumber(in: items, forRow: row) + + // Find games row for this day (if any) + var gamesRow: Int? = nil + for i in 0.. day { + break + } + } + + // Strict region classification: + // - row < gamesRow => before-games (negative sortOrder) + // - row >= gamesRow OR no games => after-games (positive sortOrder) + let isBeforeGames: Bool + if let gr = gamesRow { + isBeforeGames = row < gr + } else { + isBeforeGames = false // No games means everything is "after games" + } + + /// Get sortOrder from a movable item (custom item or travel) + func movableSortOrder(_ idx: Int) -> Double? { + guard idx >= 0 && idx < items.count else { return nil } + switch items[idx] { + case .customItem(let item): + return item.sortOrder + case .travel(let segment, _): + return findTravelSortOrder(segment) + default: + return nil + } + } + + /// Scan backward from start, stopping at boundaries, looking for movable items in the same region + func scanBackward(from start: Int) -> Double? { + var i = start + while i >= 0 { + // Stop at day boundaries + if case .dayHeader(let d, _) = items[i] { + if d != day { break } + break // Stop at own day header too + } + // Stop at games boundary (don't cross into other region) + if case .games(_, let d) = items[i], d == day { break } + + if let v = movableSortOrder(i) { + // Only return values in the correct region + if isBeforeGames { + if v < 0 { return v } + } else { + if v >= 0 { return v } + } + } + i -= 1 + } + return nil + } + + /// Scan forward from start, stopping at boundaries, looking for movable items in the same region + func scanForward(from start: Int) -> Double? { + var i = start + while i < items.count { + // Stop at day boundaries + if case .dayHeader(let d, _) = items[i] { + if d != day { break } + break // Stop at any day header + } + // Stop at games boundary (don't cross into other region) + if case .games(_, let d) = items[i], d == day { break } + + if let v = movableSortOrder(i) { + // Only return values in the correct region + if isBeforeGames { + if v < 0 { return v } + } else { + if v >= 0 { return v } + } + } + i += 1 + } + return nil + } + + if isBeforeGames { + // Above games: sortOrder should be negative + let prev = scanBackward(from: row - 1) + let next = scanForward(from: row + 1) + + let upperBound: Double = 0.0 // Games boundary + switch (prev, next) { + case (nil, nil): + return -1.0 + case (let p?, nil): + return (p + upperBound) / 2.0 + case (nil, let n?): + // First item before games: place it before the next item. + // n should always be negative (scanForward filters for region). + if n >= 0 { + // This shouldn't happen - scanForward should only return negative values + // in before-games region. Return safe default and assert in debug. + assertionFailure("Before-games region has non-negative sortOrder: \(n)") + return -1.0 + } + // Place before n by subtracting 1.0 (simpler and more consistent than min(n/2, n-1)) + return n - 1.0 + case (let p?, let n?): + return (p + n) / 2.0 + } + } else { + // Below games: sortOrder should be >= 0 + let prev = scanBackward(from: row - 1) ?? 0.0 + let next = scanForward(from: row + 1) + + switch next { + case nil: + return (prev == 0.0) ? 1.0 : (prev + 1.0) + case let n?: + return (prev + n) / 2.0 + } + } + } + + // MARK: - Valid Drop Computation + + /// Computes all valid destination rows in **proposed** coordinate space. + /// + /// For BOTH travel and custom items, we: + /// 1. Simulate the move + /// 2. Compute the resulting (day, sortOrder) + /// 3. Validate with ItineraryConstraints + /// + /// This ensures drop targets match what will actually be persisted. + /// + /// - Parameters: + /// - flatItems: The current flat items array + /// - sourceRow: The row being moved + /// - dragged: The item being dragged + /// - travelValidRanges: Valid day ranges for travel segments + /// - constraints: The constraint system for validation + /// - findTravelItem: Closure to find ItineraryItem for a travel segment + /// - makeTravelItem: Closure to create a default ItineraryItem for travel + /// - findCustomItem: Closure to find ItineraryItem for a custom item row + /// - findTravelSortOrder: Closure to find sortOrder for travel segments + /// - Returns: Array of valid row indices in proposed coordinate space + static func computeValidDestinationRowsProposed( + flatItems: [ItineraryRowItem], + sourceRow: Int, + dragged: ItineraryRowItem, + travelValidRanges: [String: ClosedRange], + constraints: ItineraryConstraints?, + findTravelItem: (TravelSegment) -> ItineraryItem?, + makeTravelItem: (TravelSegment) -> ItineraryItem, + findTravelSortOrder: @escaping (TravelSegment) -> Double? + ) -> [Int] { + let maxProposed = max(0, flatItems.count - 1) + guard maxProposed > 0 else { return [] } + + switch dragged { + case .customItem(let customItem): + // Custom items use the same simulation+validation approach as travel + guard let constraints = constraints else { + // No constraint engine: allow all rows except 0 and day headers + return (1...maxProposed).filter { proposedRow in + let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) + guard simulated.didMove else { return false } + // Don't allow dropping ON a day header + if case .dayHeader = simulated.items[simulated.destinationRowInNewArray] { + return false + } + return true + } + } + + var valid: [Int] = [] + valid.reserveCapacity(maxProposed) + + for proposedRow in 1...maxProposed { + let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) + guard simulated.didMove else { continue } + + let destRowInSim = simulated.destinationRowInNewArray + + // Don't allow dropping ON a day header + if case .dayHeader = simulated.items[destRowInSim] { + continue + } + + let day = dayNumber(in: simulated.items, forRow: destRowInSim) + let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder) + + // Create a temporary item model with the computed position + let testItem = ItineraryItem( + id: customItem.id, + tripId: customItem.tripId, + day: day, + sortOrder: sortOrder, + kind: customItem.kind + ) + + if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) { + valid.append(proposedRow) + } + } + + return valid + + case .travel(let segment, _): + let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + let validDayRange = travelValidRanges[travelId] + + // Use existing model if available, otherwise create a default + let model = findTravelItem(segment) ?? makeTravelItem(segment) + + guard let constraints = constraints else { + // No constraint engine, allow all rows except 0 and day headers + return (1...maxProposed).filter { proposedRow in + let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) + guard simulated.didMove else { return false } + if case .dayHeader = simulated.items[simulated.destinationRowInNewArray] { + return false + } + return true + } + } + + var valid: [Int] = [] + valid.reserveCapacity(maxProposed) + + for proposedRow in 1...maxProposed { + let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) + guard simulated.didMove else { continue } + + let destRowInSim = simulated.destinationRowInNewArray + + // Don't allow dropping ON a day header + if case .dayHeader = simulated.items[destRowInSim] { + continue + } + + let day = dayNumber(in: simulated.items, forRow: destRowInSim) + + // Check day range constraint (quick rejection) + if let range = validDayRange, !range.contains(day) { + continue + } + + // Check sortOrder constraint + let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder) + + // Create a testItem with computed day/sortOrder (like custom items do) + // This ensures constraints.isValidPosition sees the actual proposed position + let testItem = ItineraryItem( + id: model.id, + tripId: model.tripId, + day: day, + sortOrder: sortOrder, + kind: model.kind + ) + + if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) { + valid.append(proposedRow) + } + } + + return valid + + default: + // Day headers and games can't be moved + return [] + } + } + + // MARK: - Drag Zones + + /// Result of calculating drag zones for visual feedback. + /// + /// **COORDINATE SPACE**: All indices are in ORIGINAL coordinate space (current flatItems indices). + /// This is what the UI needs for highlighting rows before the move occurs. + struct DragZones { + /// Rows that should be dimmed/disabled in the UI (original indices) + let invalidRowIndices: Set + /// Rows where drop is allowed (original indices) + let validDropRows: [Int] + /// Game IDs that act as barriers for this drag + let barrierGameIds: Set + } + + /// Calculates drag zones for a travel segment using simulation+validation. + /// + /// This ensures UI feedback matches what will actually be accepted on drop. + /// Returns indices in ORIGINAL coordinate space for direct use in UI highlighting. + /// + /// - Parameters: + /// - segment: The travel segment being dragged + /// - sourceRow: The current row of the travel (original index) + /// - flatItems: The current flat items array + /// - travelValidRanges: Valid day ranges for travel segments + /// - constraints: The constraint system + /// - findTravelItem: Closure to find ItineraryItem for travel + /// - makeTravelItem: Closure to create a default ItineraryItem for travel + /// - findTravelSortOrder: Closure to find sortOrder for travel + /// - Returns: Drag zones with invalid rows, valid rows, and barrier game IDs (all in original space) + static func calculateTravelDragZones( + segment: TravelSegment, + sourceRow: Int, + flatItems: [ItineraryRowItem], + travelValidRanges: [String: ClosedRange], + constraints: ItineraryConstraints?, + findTravelItem: (TravelSegment) -> ItineraryItem?, + makeTravelItem: (TravelSegment) -> ItineraryItem, + findTravelSortOrder: @escaping (TravelSegment) -> Double? + ) -> DragZones { + // Get valid rows in PROPOSED coordinate space + let validRowsProposed = computeValidDestinationRowsProposed( + flatItems: flatItems, + sourceRow: sourceRow, + dragged: .travel(segment, dayNumber: 0), // dayNumber doesn't matter for validation + travelValidRanges: travelValidRanges, + constraints: constraints, + findTravelItem: findTravelItem, + makeTravelItem: makeTravelItem, + findTravelSortOrder: findTravelSortOrder + ) + + // Convert valid rows from proposed to original coordinate space + let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) } + let validSet = Set(validRowsOriginal) + + // Compute invalid rows in original coordinate space + var invalidRows = Set() + for i in 0..() + if let travelItem = findTravelItem(segment), + let constraints = constraints { + let barriers = constraints.barrierGames(for: travelItem) + barrierGameIds = Set(barriers.compactMap { $0.gameId }) + } + + return DragZones( + invalidRowIndices: invalidRows, + validDropRows: validRowsOriginal, + barrierGameIds: barrierGameIds + ) + } + + /// Calculates drag zones for a custom item using simulation+validation. + /// + /// This ensures UI feedback matches what will actually be accepted on drop. + /// Returns indices in ORIGINAL coordinate space for direct use in UI highlighting. + /// + /// - Parameters: + /// - item: The custom item being dragged + /// - sourceRow: The current row of the item (original index) + /// - flatItems: The current flat items array + /// - constraints: The constraint system + /// - findTravelSortOrder: Closure to find sortOrder for travel + /// - Returns: Drag zones with invalid rows and valid rows (all in original space) + static func calculateCustomItemDragZones( + item: ItineraryItem, + sourceRow: Int, + flatItems: [ItineraryRowItem], + constraints: ItineraryConstraints?, + findTravelSortOrder: @escaping (TravelSegment) -> Double? + ) -> DragZones { + // Get valid rows in PROPOSED coordinate space + let validRowsProposed = computeValidDestinationRowsProposed( + flatItems: flatItems, + sourceRow: sourceRow, + dragged: .customItem(item), + travelValidRanges: [:], // Custom items don't use travel ranges + constraints: constraints, + findTravelItem: { _ in nil }, + makeTravelItem: { _ in + // This won't be called for custom items + fatalError("makeTravelItem called for custom item") + }, + findTravelSortOrder: findTravelSortOrder + ) + + // Convert valid rows from proposed to original coordinate space + let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) } + let validSet = Set(validRowsOriginal) + + // Compute invalid rows in original coordinate space + var invalidRows = Set() + for i in 0..], + constraints: ItineraryConstraints?, + findTravelItem: (TravelSegment) -> ItineraryItem? + ) -> DragZones { + let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + + guard let validRange = travelValidRanges[travelId] else { + return DragZones(invalidRowIndices: [], validDropRows: [], barrierGameIds: []) + } + + var invalidRows = Set() + var validRows: [Int] = [] + + for (index, rowItem) in flatItems.enumerated() { + let dayNum: Int + switch rowItem { + case .dayHeader(let d, _): + dayNum = d + case .games(_, let d): + dayNum = d + case .travel(_, let d): + dayNum = d + case .customItem(let item): + dayNum = item.day + } + + if validRange.contains(dayNum) { + validRows.append(index) + } else { + invalidRows.insert(index) + } + } + + // Find barrier games using constraints + var barrierGameIds = Set() + if let travelItem = findTravelItem(segment), + let constraints = constraints { + let barriers = constraints.barrierGames(for: travelItem) + barrierGameIds = Set(barriers.compactMap { $0.gameId }) + } + + return DragZones( + invalidRowIndices: invalidRows, + validDropRows: validRows, + barrierGameIds: barrierGameIds + ) + } + + /// Legacy version of calculateCustomItemDragZones that doesn't require sourceRow. + /// + /// - Note: Prefer the version with sourceRow for accurate validation. + @available(*, deprecated, message: "Use calculateCustomItemDragZones(item:sourceRow:...) for accurate validation") + static func calculateCustomItemDragZones( + item: ItineraryItem, + flatItems: [ItineraryRowItem] + ) -> DragZones { + var invalidRows = Set() + var validRows: [Int] = [] + + for (index, rowItem) in flatItems.enumerated() { + if case .dayHeader = rowItem { + invalidRows.insert(index) + } else { + validRows.append(index) + } + } + + return DragZones( + invalidRowIndices: invalidRows, + validDropRows: validRows, + barrierGameIds: [] + ) + } + + // MARK: - Utility Functions + + /// Finds the nearest value in a sorted array using binary search. + /// + /// - Parameters: + /// - sorted: A sorted array of integers + /// - target: The target value to find the nearest match for + /// - Returns: The nearest value, or nil if array is empty + static func nearestValue(in sorted: [Int], to target: Int) -> Int? { + guard !sorted.isEmpty else { return nil } + + var low = 0 + var high = sorted.count + + // Binary search for insertion point + while low < high { + let mid = (low + high) / 2 + if sorted[mid] < target { + low = mid + 1 + } else { + high = mid + } + } + + let after = (low < sorted.count) ? sorted[low] : nil + let before = (low > 0) ? sorted[low - 1] : nil + + switch (before, after) { + case let (b?, a?): + // Both exist, return the closer one + return (target - b) <= (a - target) ? b : a + case let (b?, nil): + return b + case let (nil, a?): + return a + default: + return nil + } + } + + /// Calculates target destination with constraint snapping. + /// + /// If the proposed row is valid, returns it. Otherwise, snaps to nearest valid row. + /// + /// **COORDINATE SPACE**: This function expects all indices in PROPOSED coordinate space. + /// The caller must ensure validDestinationRows comes from computeValidDestinationRowsProposed. + /// + /// **UX RULE**: Row 0 is forbidden (always a day header). If proposedRow <= 0, it's clamped to 1. + /// This is a UX-level rule, not a semantic constraint - day headers cannot receive drops. + /// + /// - Parameters: + /// - proposedRow: The user's proposed drop position (in proposed coordinate space) + /// - validDestinationRows: Pre-computed valid rows from computeValidDestinationRowsProposed + /// - sourceRow: The original row (fallback if no valid destination found) + /// - Returns: The target row to use (in proposed coordinate space) + /// + /// - Note: Uses O(n) contains check. For repeated calls, consider passing a Set instead. + /// However, validDestinationRows is typically small (< 50 items), so this is fine. + static func calculateTargetRow( + proposedRow: Int, + validDestinationRows: [Int], + sourceRow: Int + ) -> Int { + // UX rule: forbid dropping at absolute top (row 0 is always a day header) + var row = proposedRow + if row <= 0 { row = 1 } + + // If already valid, use it + if validDestinationRows.contains(row) { + return row + } + + // Snap to nearest valid destination (validDestinationRows must be sorted for binary search) + return nearestValue(in: validDestinationRows, to: row) ?? sourceRow + } +} diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index 865eff3..688519f 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -462,20 +462,8 @@ final class ItineraryTableViewController: UITableViewController { /// Transforms hierarchical day data into a flat row list and refreshes the table. /// - /// This is the core data transformation method. It takes structured `[ItineraryDayData]` - /// from the wrapper and flattens it into `[ItineraryRowItem]` for UITableView display. - /// - /// **Flattening Algorithm:** - /// For each day, rows are added in this exact order: - /// 1. Travel (if arriving this day) - appears visually BEFORE the day header - /// 2. Day header (with Add button) - "Day N · Date" + tappable Add button - /// 3. Games - all games for this day (grouped as one row) - /// 4. Custom items - user-added items, already sorted by sortOrder - /// - /// **Why this order matters:** - /// - Travel before header creates visual grouping: "you travel, then you're on day N" - /// - Add button is part of header row (can't drag items between header and Add) - /// - Games before custom items preserves the "trip-determined, then user-added" hierarchy + /// Delegates to `ItineraryReorderingLogic.flattenDays` for the pure transformation, + /// then updates the table view. /// /// - Parameters: /// - days: Array of ItineraryDayData from ItineraryTableViewWrapper @@ -489,99 +477,33 @@ final class ItineraryTableViewController: UITableViewController { self.travelValidRanges = travelValidRanges self.allItineraryItems = itineraryItems self.tripDayCount = days.count - + // Rebuild constraints with new data self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems) - - flatItems = [] - - for day in days { - // 1. Travel that arrives on this day (renders BEFORE the day header) - // Example: "Detroit → Milwaukee" appears above "Day 3" header - if let travel = day.travelBefore { - flatItems.append(.travel(travel, dayNumber: day.dayNumber)) - } - - // 2. Day header with Add button (structural anchor - cannot be moved or deleted) - // Add button is embedded in the header to prevent items being dragged between them - flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) - - // 3. Movable items (travel + custom) split around games boundary. - // Convention: sortOrder < 0 renders ABOVE games; sortOrder >= 0 renders BELOW games. - var beforeGames: [ItineraryRowItem] = [] - var afterGames: [ItineraryRowItem] = [] - - for row in day.items { - let so: Double? - switch row { - case .customItem(let item): - so = item.sortOrder - case .travel(let segment, _): - // Travel sortOrder is stored in itineraryItems (kind: .travel) - so = findItineraryItem(for: segment)?.sortOrder - default: - so = nil - } - - guard let sortOrder = so else { continue } - if sortOrder < 0 { - beforeGames.append(row) - } else { - afterGames.append(row) - } - } - - flatItems.append(contentsOf: beforeGames) - - // 4. Games for this day (bundled as one row, not individually reorderable) - if !day.games.isEmpty { - flatItems.append(.games(day.games, dayNumber: day.dayNumber)) - } - - flatItems.append(contentsOf: afterGames) + + // Use pure function for flattening + flatItems = ItineraryReorderingLogic.flattenDays(days) { [weak self] segment in + self?.findItineraryItem(for: segment)?.sortOrder } - + tableView.reloadData() } - // MARK: - Row-to-Day Mapping Helpers - + // MARK: - Row-to-Day Mapping Helpers (delegating to pure functions) + /// Finds which day a row at the given index belongs to. - /// - /// Scans backwards from the row to find either: - /// - A `.dayHeader` → that's the day - /// - A `.travel` → uses the dayNumber stored in the travel item - /// - /// This is used when a custom item is dropped to determine its new day. private func dayNumber(forRow row: Int) -> Int { - for i in stride(from: row, through: 0, by: -1) { - if case .dayHeader(let dayNum, _) = flatItems[i] { - return dayNum - } - } - return 1 + ItineraryReorderingLogic.dayNumber(in: flatItems, forRow: row) } - + /// Finds the row index of the day header for a specific day number. - /// Returns nil if no header exists for that day (shouldn't happen in valid data). private func dayHeaderRow(forDay day: Int) -> Int? { - for (index, item) in flatItems.enumerated() { - if case .dayHeader(let dayNum, _) = item, dayNum == day { - return index - } - } - return nil + ItineraryReorderingLogic.dayHeaderRow(in: flatItems, forDay: day) } - + /// Finds the row index of the travel segment arriving on a specific day. - /// Returns nil if no travel arrives on that day. private func travelRow(forDay day: Int) -> Int? { - for (index, item) in flatItems.enumerated() { - if case .travel(_, let dayNum) = item, dayNum == day { - return index - } - } - return nil + ItineraryReorderingLogic.travelRow(in: flatItems, forDay: day) } // MARK: - Drag State Management @@ -647,83 +569,27 @@ final class ItineraryTableViewController: UITableViewController { } /// Calculates invalid zones for a travel segment drag. - /// - /// Travel items have hard constraints: - /// - Can't leave before finishing games in departure city - /// - Must arrive by the first game in destination city - /// - /// Invalid zones are any rows outside the valid day range. + /// Delegates to pure function and applies results to instance state. private func calculateTravelDragZones(segment: TravelSegment) { - let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" - - // Get valid day range from pre-calculated ranges - guard let validRange = travelValidRanges[travelId] else { - invalidRowIndices = [] - validDropRows = [] - barrierGameIds = [] - return - } - - // Calculate invalid and valid row indices based on day range - // Pre-calculate ALL valid positions for stable drag behavior - var invalidRows = Set() - var validRows: [Int] = [] - - for (index, rowItem) in flatItems.enumerated() { - let dayNum: Int - switch rowItem { - case .dayHeader(let d, _): - dayNum = d - case .games(_, let d): - dayNum = d - case .travel(_, let d): - dayNum = d - case .customItem(let item): - dayNum = item.day - } - - if validRange.contains(dayNum) { - validRows.append(index) - } else { - invalidRows.insert(index) - } - } - - invalidRowIndices = invalidRows - validDropRows = validRows // Already sorted since we iterate in order - - // Find barrier games using ItineraryConstraints - if let travelItem = findItineraryItem(for: segment), - let constraints = constraints { - let barriers = constraints.barrierGames(for: travelItem) - barrierGameIds = Set(barriers.compactMap { $0.gameId }) - } else { - barrierGameIds = [] - } + let zones = ItineraryReorderingLogic.calculateTravelDragZones( + segment: segment, + flatItems: flatItems, + travelValidRanges: travelValidRanges, + constraints: constraints, + findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) } + ) + invalidRowIndices = zones.invalidRowIndices + validDropRows = zones.validDropRows + barrierGameIds = zones.barrierGameIds } - + /// Calculates invalid zones for a custom item drag. - /// - /// Custom items can go on any day, but we mark certain positions as - /// less ideal (e.g., directly on day headers or before travel). + /// Delegates to pure function and applies results to instance state. private func calculateCustomItemDragZones(item: ItineraryItem) { - // Custom items are flexible - can go anywhere except ON day headers - // Pre-calculate ALL valid row indices for stable drag behavior - var invalidRows = Set() - var validRows: [Int] = [] - - for (index, rowItem) in flatItems.enumerated() { - if case .dayHeader = rowItem { - invalidRows.insert(index) - } else { - // All non-header rows are valid drop targets - validRows.append(index) - } - } - - invalidRowIndices = invalidRows - validDropRows = validRows // Already sorted since we iterate in order - barrierGameIds = [] // No barrier highlighting for custom items + let zones = ItineraryReorderingLogic.calculateCustomItemDragZones(item: item, flatItems: flatItems) + invalidRowIndices = zones.invalidRowIndices + validDropRows = zones.validDropRows + barrierGameIds = zones.barrierGameIds } /// Finds the ItineraryItem model for a travel segment. @@ -914,52 +780,16 @@ final class ItineraryTableViewController: UITableViewController { } /// Determines which day a travel segment belongs to at a given row position. - /// - /// Travel conceptually "arrives on" a day - it appears BEFORE that day's header. - /// So we scan FORWARD from the travel's position to find the next day header. - /// - /// Example: - /// ``` - /// [0] Travel: Detroit → Milwaukee ← If travel is here... - /// [1] Day 3 header ← ...it belongs to Day 3 - /// ``` private func dayForTravelAt(row: Int) -> Int { - // Scan forward to find the day header this travel precedes - for i in row.. IndexPath { - + let sourceRow = sourceIndexPath.row let item = flatItems[sourceRow] - - // Drag start detection + + // Drag start detection - initialize state and compute valid destinations if draggingItem == nil { beginDrag(at: sourceIndexPath) validDestinationRowsProposed = computeValidDestinationRowsProposed(sourceRow: sourceRow, dragged: item) } - + + // Clamp proposed row var proposedRow = proposedDestinationIndexPath.row - - // Avoid absolute top (keeps UX sane) - if proposedRow <= 0 { proposedRow = 1 } - - proposedRow = min(max(0, proposedRow), max(0, flatItems.count - 1)) - + proposedRow = min(max(1, proposedRow), max(0, flatItems.count - 1)) + // Haptics / visuals checkZoneTransition(at: proposedRow) - - // If already valid, allow it. - if validDestinationRowsProposed.contains(proposedRow) { - return IndexPath(row: proposedRow, section: 0) - } - - // Snap to nearest valid destination (proposed coordinate space) - guard let snapped = nearestValue(in: validDestinationRowsProposed, to: proposedRow) else { - return sourceIndexPath - } - return IndexPath(row: snapped, section: 0) + + // Use pure function for target calculation + let targetRow = ItineraryReorderingLogic.calculateTargetRow( + proposedRow: proposedRow, + validDestinationRows: validDestinationRowsProposed, + sourceRow: sourceRow + ) + return IndexPath(row: targetRow, section: 0) } - // MARK: - Drag Destination Precomputation (semantic day + sortOrder) - - /// Nearest value in a sorted Int array to the target (binary search). - private func nearestValue(in sorted: [Int], to target: Int) -> Int? { - guard !sorted.isEmpty else { return nil } - var low = 0 - var high = sorted.count - while low < high { - let mid = (low + high) / 2 - if sorted[mid] < target { low = mid + 1 } else { high = mid } - } - let after = (low < sorted.count) ? sorted[low] : nil - let before = (low > 0) ? sorted[low - 1] : nil - - switch (before, after) { - case let (b?, a?): - return (target - b) <= (a - target) ? b : a - case let (b?, nil): - return b - case let (nil, a?): - return a - default: - return nil - } - } - - /// Computes all valid destination rows in **proposed** coordinate space (UIKit's coordinate space during drag). - /// We simulate the move and validate using semantic constraints: (day, sortOrder). + // MARK: - Drag Destination Precomputation (delegating to pure functions) + + /// Computes all valid destination rows in **proposed** coordinate space. + /// Delegates to pure function with closures for model lookups. private func computeValidDestinationRowsProposed(sourceRow: Int, dragged: ItineraryRowItem) -> [Int] { - // Proposed rows are in the array AFTER removing the source row. - let maxProposed = max(0, flatItems.count - 1) - guard maxProposed > 0 else { return [] } - - switch dragged { - case .customItem: - // Custom items can go basically anywhere (including before headers = "between days"). - // Keep row 0 blocked. - return Array(1...maxProposed) - - case .travel(let segment, _): - let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" - let validDayRange = travelValidRanges[travelId] - - // Use existing itinerary model if available (for constraints) - let model: ItineraryItem = findItineraryItem(for: segment) ?? ItineraryItem( - tripId: allItineraryItems.first?.tripId ?? UUID(), - day: 1, - sortOrder: 0, - kind: .travel(TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name, distanceMeters: segment.distanceMeters, durationSeconds: segment.durationSeconds)) - ) - - guard let constraints else { - // If no constraint engine, allow all rows (except 0) - return Array(1...maxProposed) - } - - var valid: [Int] = [] - valid.reserveCapacity(maxProposed) - - for proposedRow in 1...maxProposed { - let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) - let destRowInSim = simulated.destinationRowInNewArray - let day = dayNumber(in: simulated.items, forRow: destRowInSim) - if let r = validDayRange, !r.contains(day) { - continue - } - let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim) - if constraints.isValidPosition(for: model, day: day, sortOrder: sortOrder) { - valid.append(proposedRow) - } - } - - return valid - - default: - return [] - } - } - - private struct SimulatedMove { - let items: [ItineraryRowItem] - let destinationRowInNewArray: Int - } - - /// Simulate UITableView move semantics: remove at sourceRow from ORIGINAL array, then insert at destinationProposedRow - /// in the NEW array (post-removal coordinate space). - private func simulateMove(original: [ItineraryRowItem], sourceRow: Int, destinationProposedRow: Int) -> SimulatedMove { - var items = original - let moving = items.remove(at: sourceRow) - let clampedDest = min(max(0, destinationProposedRow), items.count) - items.insert(moving, at: clampedDest) - return SimulatedMove(items: items, destinationRowInNewArray: clampedDest) - } - - /// Day number lookup within an arbitrary flat array (used during simulation). - private func dayNumber(in items: [ItineraryRowItem], forRow row: Int) -> Int { - guard !items.isEmpty else { return 1 } - let clamped = min(max(0, row), items.count - 1) - for i in stride(from: clamped, through: 0, by: -1) { - if case .dayHeader(let dayNum, _) = items[i] { - return dayNum - } - } - return 1 - } - - /// Calculates sortOrder for insertion at a row within an arbitrary flat array. - /// Uses the same convention as the main function: - /// - sortOrder < 0 => above games - /// - sortOrder >= 0 => below games - private func calculateSortOrder(in items: [ItineraryRowItem], at row: Int) -> Double { - let day = dayNumber(in: items, forRow: row) - - // Find games row for this day in the provided items - var gamesRow: Int? = nil - for i in 0.. day { - break - } - } - - let isBeforeGames = (gamesRow != nil && row <= gamesRow!) - - func movableSortOrder(_ idx: Int) -> Double? { - guard idx >= 0 && idx < items.count else { return nil } - switch items[idx] { - case .customItem(let item): - return item.sortOrder - case .travel(let segment, _): - return findItineraryItem(for: segment)?.sortOrder - default: - return nil - } - } - - func scanBackward(from start: Int) -> Double? { - var i = start - while i >= 0 { - if case .dayHeader(let d, _) = items[i], d != day { break } - if case .dayHeader = items[i] { break } - if case .games(_, let d) = items[i], d == day { break } - if let v = movableSortOrder(i) { - if isBeforeGames { - if v < 0 { return v } - } else { - if v >= 0 { return v } - } - } - i -= 1 - } - return nil - } - - func scanForward(from start: Int) -> Double? { - var i = start - while i < items.count { - if case .dayHeader(let d, _) = items[i], d != day { break } - if case .dayHeader = items[i] { break } - if case .games(_, let d) = items[i], d == day { break } - if let v = movableSortOrder(i) { - if isBeforeGames { - if v < 0 { return v } - } else { - if v >= 0 { return v } - } - } - i += 1 - } - return nil - } - - if isBeforeGames { - let prev = scanBackward(from: row - 1) - let next = scanForward(from: row) - - let upperBound: Double = 0.0 - switch (prev, next) { - case (nil, nil): - return -1.0 - case (let p?, nil): - return (p + upperBound) / 2.0 - case (nil, let n?): - return n / 2.0 - case (let p?, let n?): - return (p + n) / 2.0 - } - } else { - let prev = scanBackward(from: row - 1) ?? 0.0 - let next = scanForward(from: row) - - switch next { - case nil: - return (prev == 0.0) ? 1.0 : (prev + 1.0) - case let n?: - return (prev + n) / 2.0 - } - } - } - private func dayForTravelAtProposed(row: Int, excluding: Int) -> Int { - // Scan forward, skipping the item being moved - for i in row.. Double { - let day = dayNumber(forRow: row) - - // Find games row for this day (if any) - var gamesRow: Int? = nil - for i in 0.. day { - break - } - } - - let isBeforeGames = (gamesRow != nil && row <= gamesRow!) - - func movableSortOrder(_ idx: Int) -> Double? { - guard idx >= 0 && idx < flatItems.count else { return nil } - switch flatItems[idx] { - case .customItem(let item): - return item.sortOrder - case .travel(let segment, _): - return findItineraryItem(for: segment)?.sortOrder - default: - return nil - } - } - - func scanBackward(from start: Int) -> Double? { - var i = start - while i >= 0 { - if case .dayHeader(let d, _) = flatItems[i], d != day { break } - if case .dayHeader = flatItems[i] { break } - if case .games(_, let d) = flatItems[i], d == day { break } - if let v = movableSortOrder(i) { - if isBeforeGames { - if v < 0 { return v } - } else { - if v >= 0 { return v } - } - } - i -= 1 - } - return nil - } - - func scanForward(from start: Int) -> Double? { - var i = start - while i < flatItems.count { - if case .dayHeader(let d, _) = flatItems[i], d != day { break } - if case .dayHeader = flatItems[i] { break } - if case .games(_, let d) = flatItems[i], d == day { break } - if let v = movableSortOrder(i) { - if isBeforeGames { - if v < 0 { return v } - } else { - if v >= 0 { return v } - } - } - i += 1 - } - return nil - } - - if isBeforeGames { - let prev = scanBackward(from: row - 1) - let next = scanForward(from: row) - - let upperBound: Double = 0.0 // games boundary - switch (prev, next) { - case (nil, nil): - return -1.0 - case (let p?, nil): - return (p + upperBound) / 2.0 - case (nil, let n?): - return n / 2.0 - case (let p?, let n?): - return (p + n) / 2.0 - } - } else { - let prev = scanBackward(from: row - 1) ?? 0.0 - let next = scanForward(from: row) - - switch next { - case nil: - return (prev == 0.0) ? 1.0 : (prev + 1.0) - case let n?: - return (prev + n) / 2.0 - } - } + ItineraryReorderingLogic.calculateSortOrder( + in: flatItems, + at: row, + findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder } + ) } // MARK: - Cell Configuration diff --git a/SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift b/SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift new file mode 100644 index 0000000..1ba4d00 --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift @@ -0,0 +1,153 @@ +// +// ItineraryCustomItemTests.swift +// SportsTimeTests +// +// Tests for custom item movement and constraints. +// + +import XCTest +@testable import SportsTime + +private typealias H = ItineraryTestHelpers + +final class ItineraryCustomItemTests: XCTestCase { + + private let testTripId = H.testTripId + private let testDate = H.testDate + + // MARK: - Custom Item Movement Tests + + func test_customItem_canMoveToAnyDay() { + // Given: A 5-day trip + let constraints = ItineraryConstraints(tripDayCount: 5, items: []) + let itineraryItem = ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .custom(CustomInfo(title: "Dinner", icon: "🍽️"))) + + // Custom items can go on any day + for day in 1...5 { + XCTAssertTrue(constraints.isValidPosition(for: itineraryItem, day: day, sortOrder: 50), "Custom item should be valid on Day \(day)") + } + } + + func test_customItem_canMoveBeforeOrAfterGames() { + // Given: A day with a game at sortOrder 100 + let game = H.makeGameItem(city: "Detroit", day: 2, sortOrder: 100) + let constraints = ItineraryConstraints(tripDayCount: 3, items: [game]) + + let customItem = ItineraryItem(tripId: testTripId, day: 2, sortOrder: 50, kind: .custom(CustomInfo(title: "Breakfast", icon: "🍳"))) + + // Before game (sortOrder 50) - VALID + XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 50), "Custom item before game should be valid") + + // After game (sortOrder 150) - VALID + XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 150), "Custom item after game should be valid") + } + + func test_customItem_cannotBeMovedOutsideTripRange() { + // Given: A 3-day trip + let constraints = ItineraryConstraints(tripDayCount: 3, items: []) + let customItem = ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .custom(CustomInfo(title: "Test", icon: "⭐"))) + + // Day 0 (before trip) - INVALID + XCTAssertFalse(constraints.isValidPosition(for: customItem, day: 0, sortOrder: 50), "Day 0 should be invalid") + + // Day 4 (after trip) - INVALID + XCTAssertFalse(constraints.isValidPosition(for: customItem, day: 4, sortOrder: 50), "Day 4 should be invalid (trip is 3 days)") + } + + // MARK: - Move Validation Tests + + func test_moveValidation_customItem_blockedFromRow0() { + // Row 0 should always be blocked for drops + + let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Moving") + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [.customItem(customItem)], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [customItem]) + + // Attempt to move item to row 0 + let source = IndexPath(row: 1, section: 0) + let proposed = IndexPath(row: 0, section: 0) + + let result = controller.tableView(controller.tableView, targetIndexPathForMoveFromRowAt: source, toProposedIndexPath: proposed) + + // Should NOT allow row 0 + XCTAssertNotEqual(result.row, 0, "Row 0 should be blocked for drops") + } + + // MARK: - Cross-Day Movement Tests + + func test_moveItemBetweenDays_updatesDay() { + // Given: Item on Day 1, move to Day 2 + let item = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Moving") + + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [.customItem(item)], travelBefore: nil) + let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: nil) + + var capturedDay: Int = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, day, _ in + capturedDay = day + } + controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [item]) + + // Rows: 0=Day1 header, 1=item, 2=Day2 header + // Move item (row 1) to after Day2 header (row 2 becomes row 2 after move) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 2, section: 0)) + + XCTAssertEqual(capturedDay, 2, "Item should now belong to Day 2") + } + + func test_moveItem_fromLastDayToFirstDay() { + // Given: 3-day trip with item on Day 3 + // When: Moving to Day 1 + // Then: Day should be 1 + + let item = H.makeCustomItem(day: 3, sortOrder: 1.0, title: "Moving") + + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) + let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: nil) + let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [.customItem(item)], travelBefore: nil) + + var capturedDay: Int = 0 + var capturedSortOrder: Double = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, day, sortOrder in + capturedDay = day + capturedSortOrder = sortOrder + } + controller.reloadData(days: [day1, day2, day3], travelValidRanges: [:], itineraryItems: [item]) + + // Rows: 0=Day1 header, 1=Day2 header, 2=Day3 header, 3=item + // Move item to after Day1 header + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 1, section: 0)) + + XCTAssertEqual(capturedDay, 1, "Item should now be on Day 1") + XCTAssertEqual(capturedSortOrder, 1.0, accuracy: 0.01, "First item on empty day should get sortOrder 1.0") + } + + func test_moveItem_acrossMultipleDays_withGames() { + // Given: Item on Day 3, games on Day 1 + // When: Moving to Day 1 (after games) + + let game1 = H.makeRichGame(city: "Detroit", hour: 14) + let item = H.makeCustomItem(day: 3, sortOrder: 1.0, title: "Moving") + + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [game1], items: [], travelBefore: nil) + let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: nil) + let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [.customItem(item)], travelBefore: nil) + + var capturedDay: Int = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, day, _ in + capturedDay = day + } + controller.reloadData(days: [day1, day2, day3], travelValidRanges: [:], itineraryItems: [item]) + + // Rows: 0=Day1 header, 1=games, 2=Day2 header, 3=Day3 header, 4=item + // Move item to row 2 (after Day1 games, before Day2 header) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 4, section: 0), to: IndexPath(row: 2, section: 0)) + + XCTAssertEqual(capturedDay, 1, "Item moved after Day 1 games should be on Day 1") + } +} diff --git a/SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift b/SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift new file mode 100644 index 0000000..cec4031 --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift @@ -0,0 +1,67 @@ +// +// ItineraryEdgeCaseTests.swift +// SportsTimeTests +// +// Tests for edge cases in itinerary display and manipulation. +// + +import XCTest +@testable import SportsTime + +private typealias H = ItineraryTestHelpers + +final class ItineraryEdgeCaseTests: XCTestCase { + + private let testDate = H.testDate + + // MARK: - Empty/Minimal Day Tests + + func test_emptyDay_onlyShowsHeader() { + // Given: A day with no games and no items + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:]) + + let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0) + XCTAssertEqual(rowCount, 1, "Empty day should only have header row") + } + + func test_restDay_withTravelBefore_onlyShowsHeader() { + // Given: A rest day with travelBefore set (legacy field) + // Semantic model: travelBefore is IGNORED - travel must be in items to appear + let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: travel) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:]) + + let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0) + XCTAssertEqual(rowCount, 1, "travelBefore is ignored - only header should appear") + } + + func test_singleGameDay_showsHeaderAndGame() { + // Given: A day with one game + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:]) + + let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0) + XCTAssertEqual(rowCount, 2, "Day with one game should have 2 rows: header + games") + } + + // MARK: - Multi-Day Trip Tests + + func test_multiDayTrip_allDaysRepresented() { + // Given: A 5-day trip + let days = H.makeDays(count: 5) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: days, travelValidRanges: [:]) + + let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0) + XCTAssertEqual(rowCount, 5, "5-day trip with empty days should have 5 header rows") + } +} diff --git a/SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift b/SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift new file mode 100644 index 0000000..0588fe7 --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift @@ -0,0 +1,847 @@ +// +// 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 = Calendar.current.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() { + let items = buildFlatItems([ + .day(1), + .game("Detroit", day: 1), + .travel(from: "Detroit", to: "Chicago", day: 2), + .day(2), + .travel(from: "Chicago", to: "Milwaukee", day: 3), + .day(3) + ]) + + XCTAssertEqual(Logic.travelRow(in: items, forDay: 2), 2) + XCTAssertEqual(Logic.travelRow(in: items, forDay: 3), 4) + } + + func test_travelRow_noTravelOnDay_returnsNil() { + let items = buildFlatItems([ + .day(1), + .travel(from: "Detroit", to: "Chicago", day: 2), + .day(2) + ]) + + 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: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) + } +} diff --git a/SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift b/SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift new file mode 100644 index 0000000..46048ae --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift @@ -0,0 +1,183 @@ +// +// ItineraryReorderingTests.swift +// SportsTimeTests +// +// Tests for item reordering within and across days. +// + +import XCTest +@testable import SportsTime + +private typealias H = ItineraryTestHelpers + +final class ItineraryReorderingTests: XCTestCase { + + private let testDate = H.testDate + + // MARK: - Same Day Reordering Tests + + func test_reorderItems_withinSameDay_preservesCorrectOrder() { + // Given: 3 items on Day 1: A(1.0), B(2.0), C(3.0) + // When: Move C between A and B + // Then: New sortOrder for C should be 1.5 + + let itemA = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A") + let itemB = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "B") + let itemC = H.makeCustomItem(day: 1, sortOrder: 3.0, title: "C") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: [], + items: [.customItem(itemA), .customItem(itemB), .customItem(itemC)], + travelBefore: nil + ) + + var capturedSortOrder: Double = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [itemA, itemB, itemC]) + + // Rows: 0=header, 1=A(1.0), 2=B(2.0), 3=C(3.0) + // Move C (row 3) to row 2 (between A and B) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 2, section: 0)) + + XCTAssertEqual(capturedSortOrder, 1.5, accuracy: 0.01, + "Moving C between A(1.0) and B(2.0) should give sortOrder 1.5") + } + + func test_reorderItems_moveFirstToLast() { + // Given: Items A(1.0), B(2.0), C(3.0) + // When: Move A after C + // Then: New sortOrder for A should be 4.0 (last + 1.0) + + let itemA = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A") + let itemB = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "B") + let itemC = H.makeCustomItem(day: 1, sortOrder: 3.0, title: "C") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: [], + items: [.customItem(itemA), .customItem(itemB), .customItem(itemC)], + travelBefore: nil + ) + + var capturedSortOrder: Double = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [itemA, itemB, itemC]) + + // Rows: 0=header, 1=A(1.0), 2=B(2.0), 3=C(3.0) + // Move A (row 1) to row 3 (after C) + // After removing A: 0=header, 1=B, 2=C + // Insert at row 3: 0=header, 1=B, 2=C, 3=A + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 3, section: 0)) + + XCTAssertEqual(capturedSortOrder, 4.0, accuracy: 0.01, + "Moving A after C(3.0) should give sortOrder 4.0") + } + + func test_reorderItems_moveLastToFirst() { + // Given: Items A(2.0), B(4.0), C(6.0) + // When: Move C before A + // Then: New sortOrder for C should be 1.0 (first / 2.0) + + let itemA = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "A") + let itemB = H.makeCustomItem(day: 1, sortOrder: 4.0, title: "B") + let itemC = H.makeCustomItem(day: 1, sortOrder: 6.0, title: "C") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: [], + items: [.customItem(itemA), .customItem(itemB), .customItem(itemC)], + travelBefore: nil + ) + + var capturedSortOrder: Double = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [itemA, itemB, itemC]) + + // Rows: 0=header, 1=A(2.0), 2=B(4.0), 3=C(6.0) + // Move C (row 3) to row 1 (before A, after header) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 1, section: 0)) + + XCTAssertEqual(capturedSortOrder, 1.0, accuracy: 0.01, + "Moving C before A(2.0) should give sortOrder 1.0 (first/2)") + } + + // MARK: - Non-Reorderable Item Tests + + func test_games_cannotBeMoved() { + // Games should always return false for canMoveRowAt + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:]) + + // Row 0 = header, Row 1 = games + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 1, section: 0)), "Games should not be movable") + } + + func test_header_cannotBeMoved() { + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:]) + + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "Header should not be movable") + } + + // MARK: - Callback Tests + + func test_moveHeader_doesNotCallCallback() { + // Headers can't be moved, but verify no callback fires + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) + + var callbackCalled = false + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, _, _ in + callbackCalled = true + } + controller.reloadData(days: [dayData], travelValidRanges: [:]) + + // Try to move header (shouldn't be possible since canMoveRowAt returns false) + // But if someone calls moveRowAt directly: + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 0, section: 0), to: IndexPath(row: 0, section: 0)) + + XCTAssertFalse(callbackCalled, "Moving a header should not call any callback") + } + + func test_moveGames_doesNotCallCallback() { + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil) + + var callbackCalled = false + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, _, _ in + callbackCalled = true + } + controller.onTravelMoved = { _, _, _ in + callbackCalled = true + } + controller.reloadData(days: [dayData], travelValidRanges: [:]) + + // Rows: 0=header, 1=games + // Try to move games directly (shouldn't be possible) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 0, section: 0)) + + XCTAssertFalse(callbackCalled, "Moving games should not call any callback") + } +} diff --git a/SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift b/SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift new file mode 100644 index 0000000..e6d5d4e --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift @@ -0,0 +1,191 @@ +// +// ItineraryRowFlatteningTests.swift +// SportsTimeTests +// +// Tests for row flattening order and ItineraryRowItem model. +// + +import XCTest +@testable import SportsTime + +private typealias H = ItineraryTestHelpers + +final class ItineraryRowFlatteningTests: XCTestCase { + + private let testDate = H.testDate + + // MARK: - Row Flattening Order Tests + + /// Verifies that rows are flattened in correct order under SEMANTIC TRAVEL MODEL: + /// 1. Day header + /// 2. Items with sortOrder < 0 (before games, including travel) + /// 3. Games + /// 4. Items with sortOrder >= 0 (after games, including travel) + /// + /// NOTE: travelBefore is IGNORED - travel must be in items with sortOrder to appear. + func test_rowFlattening_correctOrder_semanticTravel() { + // Given: A day with travel in items (before games), games, and custom item (after games) + let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") + let travelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: -1.0) + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Dinner") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: games, + items: [.travel(travel, dayNumber: 1), .customItem(customItem)], + travelBefore: nil // travelBefore is IGNORED under semantic model + ) + + // When: Controller reloads with travel having negative sortOrder (before games) + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [travelItem, customItem]) + + // Then: Order should be: header, travel (before games), games, custom (after games) + let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0) + XCTAssertEqual(rowCount, 4, "Expected 4 rows: header, travel, games, custom item") + + // Verify order by reorderability + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "Row 0 = Header (NOT reorderable)") + XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 1, section: 0)), "Row 1 = Travel (reorderable)") + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 2, section: 0)), "Row 2 = Games (NOT reorderable)") + XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 3, section: 0)), "Row 3 = Custom item (reorderable)") + } + + func test_rowFlattening_itemsBeforeGames_negativeSortOrder() { + // Given: Custom items with negative sortOrder should appear BEFORE games + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let beforeItem = H.makeCustomItem(day: 1, sortOrder: -1.0, title: "Morning coffee") + let afterItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Dinner") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: games, + items: [.customItem(beforeItem), .customItem(afterItem)], + travelBefore: nil + ) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [beforeItem, afterItem]) + + // Then: Order should be header, beforeItem, games, afterItem + let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0) + XCTAssertEqual(rowCount, 4, "Expected 4 rows: header, before-item, games, after-item") + + // Verify the before-games item appears at row 1 (after header at row 0) + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "Row 0 should be header (not reorderable)") + XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 1, section: 0)), "Row 1 should be before-item (reorderable)") + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 2, section: 0)), "Row 2 should be games (not reorderable)") + XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 3, section: 0)), "Row 3 should be after-item (reorderable)") + } + + func test_rowFlattening_multipleItemsSortedBySortOrder() { + // Given: Multiple custom items should be sorted by sortOrder + let item1 = H.makeCustomItem(day: 1, sortOrder: 3.0, title: "Third") + let item2 = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "First") + let item3 = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Second") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: [], + items: [.customItem(item1), .customItem(item2), .customItem(item3)], + travelBefore: nil + ) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item1, item2, item3]) + + // Then: Items should appear in sortOrder: First (1.0), Second (2.0), Third (3.0) + let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0) + XCTAssertEqual(rowCount, 4, "Expected 4 rows: header + 3 items") + } + + // MARK: - Day Number Calculation Tests + + func test_dayNumber_firstDayHeader_returnsDay1() { + // Given: A simple 3-day trip + let days = H.makeDays(count: 3) + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: days, travelValidRanges: [:]) + + // The first row should be Day 1 header + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "First row should be header") + } + + func test_dayNumber_rowAfterHeader_belongsToSameDay() { + // Given: A day with games + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [dayData], travelValidRanges: [:]) + + // Row 0 = header (Day 1), Row 1 = games (belongs to Day 1) + XCTAssertEqual(controller.tableView(controller.tableView, numberOfRowsInSection: 0), 2) + } + + func test_dayNumber_travelRow_belongsToItsDay() { + // Given: Travel in Day 2's items (semantic model - travelBefore is ignored) + let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") + let travelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 1.0) + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) + let day2 = ItineraryDayData( + id: 2, + dayNumber: 2, + date: H.dayAfter(testDate), + games: [], + items: [.travel(travel, dayNumber: 2)], + travelBefore: nil // travelBefore is IGNORED under semantic model + ) + + let controller = ItineraryTableViewController(style: .plain) + controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [travelItem]) + + // Row order: Day1 header, Day2 header, travel (in day 2's after-games region) + let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0) + XCTAssertEqual(rowCount, 3, "Expected: Day1 header, Day2 header, travel") + + // Travel is reorderable and belongs to Day 2 (positioned after Day 2 header) + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "Day 1 header") + XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 1, section: 0)), "Day 2 header") + XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 2, section: 0)), "Travel is reorderable") + } + + // MARK: - ItineraryRowItem Tests + + func test_itineraryRowItem_dayHeader_hasCorrectId() { + let item = ItineraryRowItem.dayHeader(dayNumber: 3, date: testDate) + XCTAssertEqual(item.id, "day:3") + } + + func test_itineraryRowItem_games_hasCorrectId() { + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let item = ItineraryRowItem.games(games, dayNumber: 2) + XCTAssertEqual(item.id, "games:2") + } + + func test_itineraryRowItem_travel_hasLowercaseId() { + let segment = H.makeTravelSegment(from: "Chicago", to: "Detroit") + let item = ItineraryRowItem.travel(segment, dayNumber: 1) + XCTAssertEqual(item.id, "travel:chicago->detroit", "Travel ID should be lowercase") + } + + func test_itineraryRowItem_customItem_hasUuidId() { + let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Test") + let item = ItineraryRowItem.customItem(customItem) + XCTAssertTrue(item.id.hasPrefix("item:"), "Custom item ID should start with 'item:'") + } + + func test_itineraryRowItem_reorderability() { + XCTAssertFalse(ItineraryRowItem.dayHeader(dayNumber: 1, date: testDate).isReorderable) + XCTAssertFalse(ItineraryRowItem.games([], dayNumber: 1).isReorderable) + XCTAssertTrue(ItineraryRowItem.travel(H.makeTravelSegment(from: "A", to: "B"), dayNumber: 1).isReorderable) + XCTAssertTrue(ItineraryRowItem.customItem(H.makeCustomItem(day: 1, sortOrder: 1.0, title: "X")).isReorderable) + } +} diff --git a/SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift b/SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift new file mode 100644 index 0000000..c9df915 --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift @@ -0,0 +1,682 @@ +// +// 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") + } +} diff --git a/SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift b/SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift new file mode 100644 index 0000000..ee179b5 --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift @@ -0,0 +1,254 @@ +// +// ItinerarySortOrderTests.swift +// SportsTimeTests +// +// Tests for sort order calculation (midpoint insertion algorithm). +// + +import XCTest +@testable import SportsTime + +private typealias H = ItineraryTestHelpers + +final class ItinerarySortOrderTests: XCTestCase { + + private let testDate = H.testDate + + // MARK: - Midpoint Insertion Tests + + func test_sortOrder_dropBetweenItems_usesMidpoint() { + // Given: Two items with sortOrder 1.0 and 3.0 + // When: Dropping between them + // Then: New item should get sortOrder 2.0 (midpoint) + + let item1 = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "First") + let item2 = H.makeCustomItem(day: 1, sortOrder: 3.0, title: "Third") + let movingItem = H.makeCustomItem(day: 1, sortOrder: 5.0, title: "Moving") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: [], + items: [.customItem(item1), .customItem(item2), .customItem(movingItem)], + travelBefore: nil + ) + + let controller = ItineraryTableViewController(style: .plain) + var capturedSortOrder: Double = 0 + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item1, item2, movingItem]) + + // Simulate move: row 3 (movingItem) to row 2 (between item1 and item2) + // Rows: 0=header, 1=item1, 2=item2, 3=movingItem + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 2, section: 0)) + + // The new sortOrder should be midpoint between 1.0 and 3.0 = 2.0 + XCTAssertEqual(capturedSortOrder, 2.0, accuracy: 0.01, "Sort order should be midpoint (2.0)") + } + + func test_sortOrder_dropAtEnd_incrementsLastSortOrder() { + // Given: An item with sortOrder 2.0 + // When: Dropping after it + // Then: New item should get sortOrder 3.0 (last + 1.0) + + let existingItem = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Existing") + let movingItem = H.makeCustomItem(day: 2, sortOrder: 1.0, title: "Moving") + + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [.customItem(existingItem)], travelBefore: nil) + let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [.customItem(movingItem)], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + var capturedSortOrder: Double = 0 + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [existingItem, movingItem]) + + // Move item from Day 2 to end of Day 1 + // Rows: 0=Day1 header, 1=existingItem, 2=Day2 header, 3=movingItem + // Move row 3 to row 2 (after existingItem, before Day2 header) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 2, section: 0)) + + // New sortOrder should be 2.0 + 1.0 = 3.0 + XCTAssertEqual(capturedSortOrder, 3.0, accuracy: 0.01, "Sort order should be last + 1.0 = 3.0") + } + + func test_sortOrder_dropAsFirstItem_halvesPreviousSortOrder() { + // Given: An item with sortOrder 2.0 + // When: Dropping before it as first item + // Then: New item should get sortOrder 1.0 (first / 2.0) + + let existingItem = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Existing") + let movingItem = H.makeCustomItem(day: 2, sortOrder: 1.0, title: "Moving") + + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [.customItem(existingItem)], travelBefore: nil) + let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [.customItem(movingItem)], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + var capturedSortOrder: Double = 0 + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [existingItem, movingItem]) + + // Move item from Day 2 to before existingItem + // Rows: 0=Day1 header, 1=existingItem, 2=Day2 header, 3=movingItem + // Move row 3 to row 1 (before existingItem, after header) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 1, section: 0)) + + // New sortOrder should be 2.0 / 2.0 = 1.0 + XCTAssertEqual(capturedSortOrder, 1.0, accuracy: 0.01, "Sort order should be first / 2.0 = 1.0") + } + + func test_sortOrder_emptyDay_defaultsTo1() { + // Given: An empty day + // When: Dropping first item + // Then: Sort order should be 1.0 + + let movingItem = H.makeCustomItem(day: 2, sortOrder: 5.0, title: "Moving") + + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) + let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [.customItem(movingItem)], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + var capturedSortOrder: Double = 0 + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [movingItem]) + + // Move item to empty Day 1 + // Rows: 0=Day1 header, 1=Day2 header, 2=movingItem + // Move row 2 to row 1 (after Day1 header) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 2, section: 0), to: IndexPath(row: 1, section: 0)) + + XCTAssertEqual(capturedSortOrder, 1.0, accuracy: 0.01, "Sort order on empty day should be 1.0") + } + + // MARK: - scanForward Bug Tests + + /// This test explicitly targets the scanForward(from: row) bug. + /// After inserting the moved item at `row`, scanForward finds THE MOVED ITEM ITSELF + /// and returns its old sortOrder instead of the item that should come after. + func test_sortOrder_scanForwardBug_shouldNotFindMovedItemItself() { + // Given: Items with sortOrders 10.0, 20.0, 30.0 + // When: Moving item at 30.0 to between 10.0 and 20.0 + // Expected: New sortOrder = (10.0 + 20.0) / 2 = 15.0 + // Actual Bug: scanForward finds moved item (30.0), returns (10.0 + 30.0) / 2 = 20.0 + + let item1 = H.makeCustomItem(day: 1, sortOrder: 10.0, title: "A") + let item2 = H.makeCustomItem(day: 1, sortOrder: 20.0, title: "B") + let movingItem = H.makeCustomItem(day: 1, sortOrder: 30.0, title: "Moving") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: [], + items: [.customItem(item1), .customItem(item2), .customItem(movingItem)], + travelBefore: nil + ) + + let controller = ItineraryTableViewController(style: .plain) + var capturedSortOrder: Double = 0 + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item1, item2, movingItem]) + + // Rows: 0=header, 1=item1(10), 2=item2(20), 3=movingItem(30) + // Move row 3 to row 2 (between item1 and item2) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 3, section: 0), to: IndexPath(row: 2, section: 0)) + + // Expected: midpoint of 10.0 and 20.0 = 15.0 + // Bug produces: midpoint of 10.0 and 30.0 = 20.0 + XCTAssertEqual(capturedSortOrder, 15.0, accuracy: 0.01, + "Sort order should be midpoint of surrounding items (15.0), not including moved item's old sortOrder") + } + + // MARK: - Precision Tests + + func test_sortOrder_afterManyMidpointInsertions_maintainsPrecision() { + // Verify that many midpoint insertions don't cause precision issues + var sortOrders: [Double] = [1.0, 2.0] + + // Insert between 1.0 and 2.0 repeatedly (simulating many reorders) + for _ in 0..<50 { + let midpoint = (sortOrders[0] + sortOrders[1]) / 2.0 + sortOrders.insert(midpoint, at: 1) + } + + // All values should still be distinct and properly ordered + for i in 0..<(sortOrders.count - 1) { + XCTAssertLessThan(sortOrders[i], sortOrders[i + 1], "Sort orders should remain properly ordered after many insertions") + XCTAssertNotEqual(sortOrders[i], sortOrders[i + 1], "Sort orders should remain distinct after many insertions") + } + } + + // MARK: - Before/After Games Tests + + func test_moveItem_beforeGames_getsNegativeSortOrder() { + // Given: A game at sortOrder 0 (implicit), item after game at sortOrder 1.0 + // When: Moving item to before games + // Then: Should get negative sortOrder (e.g., -1.0) + + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let item = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "AfterGame") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: games, + items: [.customItem(item)], + travelBefore: nil + ) + + var capturedSortOrder: Double = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item]) + + // Rows: 0=header, 1=games, 2=item + // Move item (row 2) to row 1 (before games, after header) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 2, section: 0), to: IndexPath(row: 1, section: 0)) + + XCTAssertLessThan(capturedSortOrder, 0, "Item moved before games should have negative sortOrder") + } + + func test_moveItem_afterGames_getsPositiveSortOrder() { + // Given: A game, item before game at sortOrder -1.0 + // When: Moving item to after games + // Then: Should get positive sortOrder + + let games = [H.makeRichGame(city: "Detroit", hour: 19)] + let item = H.makeCustomItem(day: 1, sortOrder: -1.0, title: "BeforeGame") + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: games, + items: [.customItem(item)], + travelBefore: nil + ) + + var capturedSortOrder: Double = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onCustomItemMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item]) + + // Rows: 0=header, 1=item(-1.0), 2=games + // Move item (row 1) to row 2 (after games) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 2, section: 0)) + + XCTAssertGreaterThan(capturedSortOrder, 0, "Item moved after games should have positive sortOrder") + } +} diff --git a/SportsTimeTests/Features/Trip/ItineraryTestHelpers.swift b/SportsTimeTests/Features/Trip/ItineraryTestHelpers.swift new file mode 100644 index 0000000..84389a8 --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItineraryTestHelpers.swift @@ -0,0 +1,125 @@ +// +// ItineraryTestHelpers.swift +// SportsTimeTests +// +// Shared test fixtures and helpers for Itinerary tests. +// + +import Foundation +@testable import SportsTime + +/// Shared test fixtures for itinerary tests +enum ItineraryTestHelpers { + static let testTripId = UUID() + static let testDate = Date() + + // MARK: - Day Helpers + + static func makeDays(count: Int, from baseDate: Date = testDate) -> [ItineraryDayData] { + return (0.. Date { + Calendar.current.date(byAdding: .day, value: 1, to: date)! + } + + // MARK: - Travel Helpers + + static func makeTravelSegment(from: String, to: String) -> TravelSegment { + TravelSegment( + fromLocation: LocationInput(name: from, coordinate: nil), + toLocation: LocationInput(name: to, coordinate: nil), + travelMode: .drive, + distanceMeters: 500_000, + durationSeconds: 18000 + ) + } + + static func makeTravelItem(from: String, to: String, day: Int, sortOrder: Double) -> ItineraryItem { + ItineraryItem( + tripId: testTripId, + day: day, + sortOrder: sortOrder, + kind: .travel(TravelInfo(fromCity: from, toCity: to)) + ) + } + + // MARK: - Game Helpers + + static func makeRichGame(city: String, hour: Int, baseDate: Date = testDate) -> RichGame { + var dateComponents = Calendar.current.dateComponents([.year, .month, .day], from: baseDate) + dateComponents.hour = hour + let gameTime = Calendar.current.date(from: dateComponents)! + + let game = Game( + id: "game-\(city)-\(UUID().uuidString.prefix(4))", + homeTeamId: "team-\(city)", + awayTeamId: "team-visitor", + stadiumId: "stadium-\(city)", + dateTime: gameTime, + sport: .mlb, + season: "2026", + isPlayoff: false + ) + + let stadium = Stadium( + id: "stadium-\(city)", + name: "\(city) Stadium", + city: city, + state: "XX", + latitude: 40.0, + longitude: -80.0, + capacity: 40000, + sport: .mlb + ) + + let homeTeam = Team( + id: "team-\(city)", + name: "\(city) Team", + abbreviation: String(city.prefix(3)).uppercased(), + sport: .mlb, + city: city, + stadiumId: "stadium-\(city)" + ) + + let awayTeam = Team( + id: "team-visitor", + name: "Visitor Team", + abbreviation: "VIS", + sport: .mlb, + city: "Visiting", + stadiumId: "stadium-visitor" + ) + + return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) + } + + static func makeGameItem(city: String, day: Int, sortOrder: Double = 100) -> ItineraryItem { + ItineraryItem( + tripId: testTripId, + day: day, + sortOrder: sortOrder, + kind: .game(gameId: "game-\(city)-\(UUID().uuidString.prefix(4))") + ) + } + + // MARK: - Custom Item Helpers + + static func makeCustomItem(day: Int, sortOrder: Double, title: String) -> ItineraryItem { + ItineraryItem( + tripId: testTripId, + day: day, + sortOrder: sortOrder, + kind: .custom(CustomInfo(title: title, icon: "🍽️")) + ) + } +} diff --git a/SportsTimeTests/Features/Trip/ItineraryTravelConstraintTests.swift b/SportsTimeTests/Features/Trip/ItineraryTravelConstraintTests.swift new file mode 100644 index 0000000..f228900 --- /dev/null +++ b/SportsTimeTests/Features/Trip/ItineraryTravelConstraintTests.swift @@ -0,0 +1,287 @@ +// +// ItineraryTravelConstraintTests.swift +// SportsTimeTests +// +// Tests for travel segment movement constraints. +// + +import XCTest +@testable import SportsTime + +private typealias H = ItineraryTestHelpers + +final class ItineraryTravelConstraintTests: XCTestCase { + + private let testTripId = H.testTripId + private let testDate = H.testDate + + // MARK: - Travel Constraint Tests + + func test_travel_cannotMoveBeforeLastDepartureGame() { + // Given: Chicago has games on Days 1-2, Detroit has game on Day 4 + // Travel Chicago → Detroit valid range: Days 2-4 + // Travel cannot be on Day 1 (before last Chicago game) + + let chicagoGame1 = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) + let chicagoGame2 = H.makeGameItem(city: "Chicago", day: 2, sortOrder: 100) + let detroitGame = H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100) + + let constraints = ItineraryConstraints( + tripDayCount: 5, + items: [chicagoGame1, chicagoGame2, detroitGame] + ) + + let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50) + + // Travel on Day 1 should be INVALID (Chicago game on Day 2 not finished) + XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50), "Travel on Day 1 should be invalid - must wait for Day 2 Chicago game") + + // Travel on Day 2 after the game should be VALID + XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 150), "Travel on Day 2 after game should be valid") + } + + func test_travel_cannotMoveAfterFirstArrivalGame() { + // Given: Detroit has games on Days 3-4 + // Travel to Detroit must arrive by Day 3 (before first game) + + let chicagoGame = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) + let detroitGame1 = H.makeGameItem(city: "Detroit", day: 3, sortOrder: 100) + let detroitGame2 = H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100) + + let constraints = ItineraryConstraints( + tripDayCount: 5, + items: [chicagoGame, detroitGame1, detroitGame2] + ) + + let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 4, sortOrder: 50) + + // Travel on Day 4 should be INVALID (missed Day 3 Detroit game) + XCTAssertFalse(constraints.isValidPosition(for: travel, day: 4, sortOrder: 50), "Travel on Day 4 should be invalid - missed Day 3 game") + + // Travel on Day 3 before the game should be VALID + XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50), "Travel on Day 3 before game should be valid") + } + + func test_travel_onEdgeDay_mustRespectSortOrderConstraints() { + // Given: Chicago game on Day 1 at sortOrder 100 + // Travel on Day 1 must have sortOrder > 100 (after the game) + + let chicagoGame = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) + let constraints = ItineraryConstraints(tripDayCount: 3, items: [chicagoGame]) + let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50) + + // SortOrder 50 is BEFORE the game - INVALID + XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50), "Travel before game on same day should be invalid") + + // SortOrder 100 is AT the game - INVALID (must be strictly after) + XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 100), "Travel at same sortOrder as game should be invalid") + + // SortOrder 150 is AFTER the game - VALID + XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150), "Travel after game on same day should be valid") + } + + func test_travel_onArrivalDay_mustBeBeforeGame() { + // Given: Detroit game on Day 3 at sortOrder 100 + // Travel arriving Day 3 must have sortOrder < 100 (before the game) + + let detroitGame = H.makeGameItem(city: "Detroit", day: 3, sortOrder: 100) + let constraints = ItineraryConstraints(tripDayCount: 3, items: [detroitGame]) + let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 150) + + // SortOrder 150 is AFTER the game - INVALID + XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 150), "Travel after game on arrival day should be invalid") + + // SortOrder 100 is AT the game - INVALID + XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 100), "Travel at same sortOrder as arrival game should be invalid") + + // SortOrder 50 is BEFORE the game - VALID + XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50), "Travel before game on arrival day should be valid") + } + + func test_travel_validDayRange_calculatedCorrectly() { + // Given: Chicago games Days 1-2, Detroit games Days 4-5 + // Travel valid range should be Days 2-4 + + let games = [ + H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100), + H.makeGameItem(city: "Chicago", day: 2, sortOrder: 100), + H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100), + H.makeGameItem(city: "Detroit", day: 5, sortOrder: 100) + ] + + let constraints = ItineraryConstraints(tripDayCount: 6, items: games) + let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50) + + let range = constraints.validDayRange(for: travel) + XCTAssertEqual(range, 2...4, "Valid range should be Days 2-4") + } + + func test_travel_impossibleConstraints_returnsNil() { + // Given: Chicago game on Day 3, Detroit game on Day 1 + // This is impossible - can't leave after Day 3 and arrive by Day 1 + + let games = [ + H.makeGameItem(city: "Chicago", day: 3, sortOrder: 100), + H.makeGameItem(city: "Detroit", day: 1, sortOrder: 100) + ] + + let constraints = ItineraryConstraints(tripDayCount: 3, items: games) + let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 50) + + let range = constraints.validDayRange(for: travel) + XCTAssertNil(range, "Impossible constraints should return nil range") + } + + // MARK: - Barrier Games Tests + + func test_barrierGames_identifiesCorrectGames() { + // Given: Chicago games Days 1-2, Detroit games Days 4-5 + // Barriers should be: last Chicago game (Day 2) and first Detroit game (Day 4) + + let chicagoGame1 = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) + let chicagoGame2 = H.makeGameItem(city: "Chicago", day: 2, sortOrder: 100) + let detroitGame1 = H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100) + let detroitGame2 = H.makeGameItem(city: "Detroit", day: 5, sortOrder: 100) + + let constraints = ItineraryConstraints( + tripDayCount: 6, + items: [chicagoGame1, chicagoGame2, detroitGame1, detroitGame2] + ) + + let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50) + + let barriers = constraints.barrierGames(for: travel) + + XCTAssertEqual(barriers.count, 2, "Should identify 2 barrier games") + XCTAssertTrue(barriers.contains { $0.id == chicagoGame2.id }, "Should include last Chicago game") + XCTAssertTrue(barriers.contains { $0.id == detroitGame1.id }, "Should include first Detroit game") + } + + // MARK: - Travel with Games on Same Day Tests + + func test_travel_departureDay_sortOrderMustBeAfterLastGame() { + // Given: Chicago game at sortOrder 100, travel from Chicago on same day + // Travel sortOrder must be > 100 + + let chicagoGame = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) + let travel = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 150) + + let dayData = ItineraryDayData( + id: 1, + dayNumber: 1, + date: testDate, + games: [H.makeRichGame(city: "Chicago", hour: 19)], + items: [.travel(H.makeTravelSegment(from: "Chicago", to: "Detroit"), dayNumber: 1)], + travelBefore: nil + ) + + let controller = ItineraryTableViewController(style: .plain) + var capturedSortOrder: Double = 0 + controller.onTravelMoved = { _, _, sortOrder in + capturedSortOrder = sortOrder + } + controller.reloadData( + days: [dayData], + travelValidRanges: ["travel:chicago->detroit": 1...1], + itineraryItems: [chicagoGame, travel] + ) + + // Rows: 0=header, 1=games, 2=travel + // Travel is already at valid position, just verify it stays after games + XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 2, section: 0))) + } + + // MARK: - Travel Movement Tests + + func test_travel_moveToValidDay_callsCallback() { + // Given: Travel with valid range 2-4 + let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") + + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) + let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: travel) + let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [], travelBefore: nil) + + var capturedTravelId: String = "" + var capturedDay: Int = 0 + let controller = ItineraryTableViewController(style: .plain) + controller.onTravelMoved = { travelId, day, _ in + capturedTravelId = travelId + capturedDay = day + } + controller.reloadData( + days: [day1, day2, day3], + travelValidRanges: ["travel:chicago->detroit": 2...3], + itineraryItems: [] + ) + + // Rows: 0=Day1 header, 1=travel, 2=Day2 header, 3=Day3 header + // Move travel (row 1) to row 3 (after Day2, before Day3 header means Day 3) + controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 3, section: 0)) + + XCTAssertEqual(capturedTravelId, "travel:chicago->detroit") + XCTAssertEqual(capturedDay, 3, "Travel should now be on Day 3") + } + + // MARK: - Move Validation Tests + + func test_moveValidation_travel_snapsToValidDayRange() { + // Given: Travel with valid range Days 2-3 + let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") + let travelId = "travel:chicago->detroit" + + let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) + let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: travel) + let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [], travelBefore: nil) + + let controller = ItineraryTableViewController(style: .plain) + let validRanges = [travelId: 2...3] + controller.reloadData(days: [day1, day2, day3], travelValidRanges: validRanges) + + // Travel is at row 1 (after Day1 header at row 0) + // Try to move it to Day 1 area (row 0 or 1) - should snap back to valid range + let source = IndexPath(row: 1, section: 0) + let proposed = IndexPath(row: 0, section: 0) + + let result = controller.tableView(controller.tableView, targetIndexPathForMoveFromRowAt: source, toProposedIndexPath: proposed) + + // Result should NOT be row 0 (Day 1 is outside valid range) + XCTAssertGreaterThan(result.row, 0, "Travel should snap away from invalid Day 1") + } + + // MARK: - Complex Scenario + + func test_complexScenario_multiCityTripWithConstraints() { + // Given: A 7-day trip with: + // - Chicago games Days 1-2 + // - Travel Chicago → Detroit (valid Days 2-4) + // - Detroit games Days 4-5 + // - Travel Detroit → Milwaukee (valid Days 5-6) + // - Milwaukee game Day 6 + + let chicagoGame1 = H.makeGameItem(city: "Chicago", day: 1, sortOrder: 100) + let chicagoGame2 = H.makeGameItem(city: "Chicago", day: 2, sortOrder: 100) + let detroitGame1 = H.makeGameItem(city: "Detroit", day: 4, sortOrder: 100) + let detroitGame2 = H.makeGameItem(city: "Detroit", day: 5, sortOrder: 100) + let milwaukeeGame = H.makeGameItem(city: "Milwaukee", day: 6, sortOrder: 100) + + let constraints = ItineraryConstraints( + tripDayCount: 7, + items: [chicagoGame1, chicagoGame2, detroitGame1, detroitGame2, milwaukeeGame] + ) + + // Travel 1: Chicago → Detroit + let travel1 = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50) + XCTAssertEqual(constraints.validDayRange(for: travel1), 2...4) + + // Travel 2: Detroit → Milwaukee + let travel2 = H.makeTravelItem(from: "Detroit", to: "Milwaukee", day: 5, sortOrder: 150) + XCTAssertEqual(constraints.validDayRange(for: travel2), 5...6) + + // Invalid positions + XCTAssertFalse(constraints.isValidPosition(for: travel1, day: 1, sortOrder: 50), "Travel1 on Day 1 invalid") + XCTAssertFalse(constraints.isValidPosition(for: travel1, day: 5, sortOrder: 50), "Travel1 on Day 5 invalid") + + XCTAssertFalse(constraints.isValidPosition(for: travel2, day: 4, sortOrder: 50), "Travel2 on Day 4 invalid") + XCTAssertFalse(constraints.isValidPosition(for: travel2, day: 7, sortOrder: 50), "Travel2 on Day 7 invalid") + } +}