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 <noreply@anthropic.com>
This commit is contained in:
921
SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift
Normal file
921
SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift
Normal file
@@ -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..<flatItems.count
|
||||
// - Used by: DragZones (invalidRowIndices, validDropRows), UI highlighting
|
||||
// - Source row is always specified in original space
|
||||
//
|
||||
// 2. PROPOSED SPACE (UITableView post-removal)
|
||||
// - Row indices after sourceRow is removed from the array
|
||||
// - After removal: array has count-1 elements, valid insert positions are 0...(count-1)
|
||||
// - Used by: UITableView delegate methods, computeValidDestinationRowsProposed return value
|
||||
// - Proposed index N means: remove source, insert at position N in the remaining array
|
||||
//
|
||||
// FUNCTION REFERENCE:
|
||||
// - simulateMove: Takes PROPOSED index → returns post-move array + actual destination
|
||||
// - computeValidDestinationRowsProposed: Returns PROPOSED indices (for tableView delegate)
|
||||
// - calculateSortOrder: Takes row in POST-MOVE array (item already at destination)
|
||||
// - calculateTravelDragZones/calculateCustomItemDragZones: Return ORIGINAL indices
|
||||
//
|
||||
// COORDINATE CONVERSION:
|
||||
// - proposedToOriginal(proposed, sourceRow): Converts proposed → original
|
||||
// • If proposed >= 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)..<items.count {
|
||||
switch items[i] {
|
||||
case .dayHeader:
|
||||
// Reached next day, no travel found
|
||||
return nil
|
||||
case .travel:
|
||||
return i
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Legacy version that uses embedded dayNumber (unreliable under semantic model).
|
||||
@available(*, deprecated, message: "Use travelRow(in:forDay:) which uses semantic day lookup")
|
||||
static func travelRowByEmbeddedDay(in items: [ItineraryRowItem], forDay day: Int) -> 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..<items.count {
|
||||
if case .games(_, let d) = items[i], d == day {
|
||||
gamesRow = i
|
||||
break
|
||||
}
|
||||
if case .dayHeader(let d, _) = items[i], d > 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<Int>],
|
||||
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<Int>
|
||||
/// Rows where drop is allowed (original indices)
|
||||
let validDropRows: [Int]
|
||||
/// Game IDs that act as barriers for this drag
|
||||
let barrierGameIds: Set<String>
|
||||
}
|
||||
|
||||
/// 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<Int>],
|
||||
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<Int>()
|
||||
for i in 0..<flatItems.count {
|
||||
if i == sourceRow {
|
||||
// The source row itself is neither valid nor invalid - it's being dragged
|
||||
continue
|
||||
}
|
||||
if !validSet.contains(i) {
|
||||
invalidRows.insert(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Find barrier games using constraints
|
||||
var barrierGameIds = Set<String>()
|
||||
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<Int>()
|
||||
for i in 0..<flatItems.count {
|
||||
if i == sourceRow {
|
||||
// The source row itself is neither valid nor invalid - it's being dragged
|
||||
continue
|
||||
}
|
||||
if !validSet.contains(i) {
|
||||
invalidRows.insert(i)
|
||||
}
|
||||
}
|
||||
|
||||
return DragZones(
|
||||
invalidRowIndices: invalidRows,
|
||||
validDropRows: validRowsOriginal,
|
||||
barrierGameIds: [] // No barrier highlighting for custom items
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Legacy Compatibility
|
||||
|
||||
/// Legacy version of calculateTravelDragZones that doesn't require sourceRow.
|
||||
/// Uses day-range-based calculation only.
|
||||
///
|
||||
/// - Note: Prefer the version with sourceRow for accurate validation.
|
||||
@available(*, deprecated, message: "Use calculateTravelDragZones(segment:sourceRow:...) for accurate validation")
|
||||
static func calculateTravelDragZones(
|
||||
segment: TravelSegment,
|
||||
flatItems: [ItineraryRowItem],
|
||||
travelValidRanges: [String: ClosedRange<Int>],
|
||||
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<Int>()
|
||||
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<String>()
|
||||
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<Int>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Int>()
|
||||
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<Int>()
|
||||
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..<flatItems.count {
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
// Fallback: scan backwards to find any day header
|
||||
for i in stride(from: flatItems.count - 1, through: 0, by: -1) {
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
return 1 // Ultimate fallback
|
||||
ItineraryReorderingLogic.dayForTravelAt(row: row, in: flatItems)
|
||||
}
|
||||
|
||||
/// Called DURING a drag to validate and potentially modify the drop position.
|
||||
///
|
||||
/// This is the core drag constraint logic. UITableView calls this continuously
|
||||
/// as the user drags, allowing us to redirect the drop to a valid position.
|
||||
///
|
||||
/// **Key behaviors:**
|
||||
///
|
||||
/// **Travel segments:** Constrained to their valid day range. If user tries to
|
||||
/// drag outside the range, we snap to the nearest valid day. This prevents
|
||||
/// impossible itineraries (e.g., arriving before you've left).
|
||||
///
|
||||
/// **Custom items:** Can go almost anywhere, but we prevent:
|
||||
/// - Dropping ON a day header (redirect to after header)
|
||||
/// - Dropping BEFORE travel at start of day (redirect to after header)
|
||||
///
|
||||
/// **Fixed items:** Day headers, games, add buttons return their source position
|
||||
/// (they never actually drag since canMoveRowAt returns false).
|
||||
///
|
||||
/// **Drag State Management:**
|
||||
/// - First call: Initializes drag state, calculates invalid zones, triggers pickup haptic
|
||||
/// - Subsequent calls: Checks zone transitions for haptic feedback
|
||||
/// Delegates constraint logic to pure functions, handles only UIKit-specific concerns:
|
||||
/// - Drag state initialization (first call)
|
||||
/// - Haptic/visual feedback
|
||||
/// - Converting pure function results to IndexPath
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sourceIndexPath: Where the item is being dragged FROM
|
||||
@@ -970,256 +800,59 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
|
||||
toProposedIndexPath proposedDestinationIndexPath: IndexPath
|
||||
) -> 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..<items.count {
|
||||
if case .games(_, let d) = items[i], d == day {
|
||||
gamesRow = i
|
||||
break
|
||||
}
|
||||
if case .dayHeader(let d, _) = items[i], d > 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..<flatItems.count {
|
||||
if i == excluding { continue }
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
// Fallback: scan backwards
|
||||
for i in stride(from: flatItems.count - 1, through: 0, by: -1) {
|
||||
if i == excluding { continue }
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
return 1
|
||||
ItineraryReorderingLogic.computeValidDestinationRowsProposed(
|
||||
flatItems: flatItems,
|
||||
sourceRow: sourceRow,
|
||||
dragged: dragged,
|
||||
travelValidRanges: travelValidRanges,
|
||||
constraints: constraints,
|
||||
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) },
|
||||
makeTravelItem: { [weak self] segment in
|
||||
ItineraryItem(
|
||||
tripId: self?.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
|
||||
))
|
||||
)
|
||||
},
|
||||
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Editing Style Configuration
|
||||
@@ -1297,120 +930,15 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
}
|
||||
|
||||
// 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:
|
||||
///
|
||||
/// ```
|
||||
/// Existing items: A (sortOrder: 1.0) B (sortOrder: 2.0)
|
||||
/// Drop between: A ← DROP HERE → B
|
||||
/// New sortOrder: 1.5 (midpoint of 1.0 and 2.0)
|
||||
/// ```
|
||||
///
|
||||
/// **Edge cases:**
|
||||
/// - First item in empty day: sortOrder = 1.0
|
||||
/// - After last item: sortOrder = last + 1.0
|
||||
/// - Before first item: sortOrder = first / 2.0
|
||||
///
|
||||
/// **Precision:** Double has ~15 significant digits. Even with millions of midpoint
|
||||
/// insertions, precision remains sufficient. Example worst case:
|
||||
/// - 50 insertions between 1.0 and 2.0: sortOrder ≈ 1.0000000000000009
|
||||
/// - Still distinguishable and orderable
|
||||
///
|
||||
/// **Scanning logic:** We scan backwards and forwards from the drop position
|
||||
/// to find adjacent custom items, stopping at day boundaries (headers, travel).
|
||||
/// Delegates to pure function with closure for travel sortOrder lookup.
|
||||
private func calculateSortOrder(at row: Int) -> Double {
|
||||
let day = dayNumber(forRow: row)
|
||||
|
||||
// Find games row for this day (if any)
|
||||
var gamesRow: Int? = nil
|
||||
for i in 0..<flatItems.count {
|
||||
if case .games(_, let d) = flatItems[i], d == day {
|
||||
gamesRow = i
|
||||
break
|
||||
}
|
||||
if case .dayHeader(let d, _) = flatItems[i], d > 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
|
||||
|
||||
153
SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift
Normal file
153
SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
67
SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift
Normal file
67
SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
183
SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift
Normal file
183
SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
191
SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift
Normal file
191
SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
682
SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift
Normal file
682
SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift
Normal file
@@ -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..<items.count {
|
||||
let simulated = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: proposedRow)
|
||||
guard simulated.didMove else { continue }
|
||||
|
||||
let destRow = simulated.destinationRowInNewArray
|
||||
|
||||
// Skip day headers
|
||||
if case .dayHeader = simulated.items[destRow] {
|
||||
XCTAssertFalse(validRows.contains(proposedRow),
|
||||
"Day header row \(proposedRow) should not be valid")
|
||||
continue
|
||||
}
|
||||
|
||||
let day = Logic.dayNumber(in: simulated.items, forRow: destRow)
|
||||
let sortOrder = Logic.calculateSortOrder(in: simulated.items, at: destRow) { _ in 1.0 }
|
||||
|
||||
let constraintSaysValid = constraints.isValidPosition(for: travelItem, day: day, sortOrder: sortOrder)
|
||||
let functionSaysValid = validRows.contains(proposedRow)
|
||||
|
||||
XCTAssertEqual(constraintSaysValid, functionSaysValid,
|
||||
"Row \(proposedRow) → day \(day), sortOrder \(sortOrder): " +
|
||||
"constraint says \(constraintSaysValid), function says \(functionSaysValid)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom item should also match constraints validation.
|
||||
func test_E_customItemValidDestinations_matchesConstraints() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Lunch")
|
||||
let day2Date = Calendar.current.date(byAdding: .day, value: 1, to: testDate)!
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.games([game], dayNumber: 1),
|
||||
.customItem(customItem), // Source row 2
|
||||
.dayHeader(dayNumber: 2, date: day2Date)
|
||||
]
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 2, items: [])
|
||||
|
||||
let validRows = Logic.computeValidDestinationRowsProposed(
|
||||
flatItems: items,
|
||||
sourceRow: 2,
|
||||
dragged: .customItem(customItem),
|
||||
travelValidRanges: [:],
|
||||
constraints: constraints,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { segment in
|
||||
let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name)
|
||||
return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 0, kind: .travel(info))
|
||||
},
|
||||
findTravelSortOrder: { _ in nil }
|
||||
)
|
||||
|
||||
// Verify each row matches constraint
|
||||
for proposedRow in 1..<items.count {
|
||||
let simulated = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: proposedRow)
|
||||
guard simulated.didMove else { continue }
|
||||
|
||||
let destRow = simulated.destinationRowInNewArray
|
||||
|
||||
// Skip day headers
|
||||
if case .dayHeader = simulated.items[destRow] {
|
||||
XCTAssertFalse(validRows.contains(proposedRow),
|
||||
"Day header row \(proposedRow) should not be valid")
|
||||
continue
|
||||
}
|
||||
|
||||
let day = Logic.dayNumber(in: simulated.items, forRow: destRow)
|
||||
let sortOrder = Logic.calculateSortOrder(in: simulated.items, at: destRow) { _ in nil }
|
||||
|
||||
let testItem = ItineraryItem(
|
||||
id: customItem.id,
|
||||
tripId: customItem.tripId,
|
||||
day: day,
|
||||
sortOrder: sortOrder,
|
||||
kind: customItem.kind
|
||||
)
|
||||
|
||||
let constraintSaysValid = constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder)
|
||||
let functionSaysValid = validRows.contains(proposedRow)
|
||||
|
||||
XCTAssertEqual(constraintSaysValid, functionSaysValid,
|
||||
"Custom item row \(proposedRow) → day \(day), sortOrder \(sortOrder): mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SimulateMove Bounds Safety
|
||||
|
||||
func test_simulateMove_negativeSourceRow_returnsOriginal() {
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate)
|
||||
]
|
||||
|
||||
let result = Logic.simulateMove(original: items, sourceRow: -1, destinationProposedRow: 0)
|
||||
|
||||
XCTAssertFalse(result.didMove, "Invalid sourceRow should not move")
|
||||
XCTAssertEqual(result.items.count, 1)
|
||||
}
|
||||
|
||||
func test_simulateMove_sourceRowOutOfBounds_returnsOriginal() {
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate)
|
||||
]
|
||||
|
||||
let result = Logic.simulateMove(original: items, sourceRow: 5, destinationProposedRow: 0)
|
||||
|
||||
XCTAssertFalse(result.didMove, "Out of bounds sourceRow should not move")
|
||||
XCTAssertEqual(result.items.count, 1)
|
||||
}
|
||||
|
||||
func test_simulateMove_validSourceRow_didMoveIsTrue() {
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
|
||||
]
|
||||
|
||||
let result = Logic.simulateMove(original: items, sourceRow: 0, destinationProposedRow: 1)
|
||||
|
||||
XCTAssertTrue(result.didMove, "Valid sourceRow should move")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: Travel Never Disappears
|
||||
|
||||
/// Travel in items must appear even if findTravelSortOrder returns nil.
|
||||
func test_flattenDays_travelAppearsEvenWithNilSortOrder() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
let travelItem = ItineraryRowItem.travel(travel, dayNumber: 1)
|
||||
|
||||
let days = [
|
||||
ItineraryDayData(
|
||||
id: 1,
|
||||
dayNumber: 1,
|
||||
date: testDate,
|
||||
games: [],
|
||||
items: [travelItem],
|
||||
travelBefore: nil
|
||||
)
|
||||
]
|
||||
|
||||
// Return nil for sortOrder lookup - should use default (1.0)
|
||||
let result = Logic.flattenDays(days) { _ in nil }
|
||||
|
||||
let travelCount = result.filter { row in
|
||||
if case .travel = row { return true }
|
||||
return false
|
||||
}.count
|
||||
|
||||
XCTAssertEqual(travelCount, 1, "Travel must appear even when sortOrder lookup returns nil")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: travelRow Semantic Lookup
|
||||
|
||||
/// travelRow must find travel by scanning day section, not by embedded dayNumber.
|
||||
func test_travelRow_usesSemanticDayLookup() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
// Travel has embedded dayNumber 99 (wrong), but is positioned after day 2 header
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate)),
|
||||
.travel(travel, dayNumber: 99) // Wrong embedded dayNumber
|
||||
]
|
||||
|
||||
// Semantic lookup should find travel in day 2 section
|
||||
let result = Logic.travelRow(in: items, forDay: 2)
|
||||
|
||||
XCTAssertEqual(result, 2, "travelRow should find travel in day 2 section by position, not embedded dayNumber")
|
||||
}
|
||||
|
||||
/// travelRow returns nil if no travel in that day section.
|
||||
func test_travelRow_returnsNilIfNoTravelInDaySection() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.travel(travel, dayNumber: 1), // Travel in day 1
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
|
||||
]
|
||||
|
||||
// Day 2 has no travel
|
||||
let result = Logic.travelRow(in: items, forDay: 2)
|
||||
|
||||
XCTAssertNil(result, "travelRow should return nil when no travel in day section")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: calculateSortOrder Region Correctness
|
||||
|
||||
/// Before-games region must always return negative sortOrder.
|
||||
func test_calculateSortOrder_beforeGamesRegion_alwaysNegative() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Morning")
|
||||
|
||||
// Item placed BEFORE games row
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate), // Row 0
|
||||
.customItem(customItem), // Row 1 - before games
|
||||
.games([game], dayNumber: 1) // Row 2
|
||||
]
|
||||
|
||||
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
|
||||
|
||||
XCTAssertLessThan(sortOrder, 0, "Item before games must have negative sortOrder")
|
||||
}
|
||||
|
||||
/// After-games region must always return non-negative sortOrder.
|
||||
func test_calculateSortOrder_afterGamesRegion_alwaysNonNegative() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Evening")
|
||||
|
||||
// Item placed AFTER games row
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.games([game], dayNumber: 1),
|
||||
.customItem(customItem) // Row 2 - after games
|
||||
]
|
||||
|
||||
let sortOrder = Logic.calculateSortOrder(in: items, at: 2) { _ in nil }
|
||||
|
||||
XCTAssertGreaterThanOrEqual(sortOrder, 0, "Item after games must have non-negative sortOrder")
|
||||
}
|
||||
|
||||
/// First item in before-games region gets proper negative sortOrder.
|
||||
func test_calculateSortOrder_firstItemBeforeGames_getsNegativeValue() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
// Create a custom item to place before games (simulating a moved item)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: -1.0, title: "Pre-game activity")
|
||||
|
||||
// This represents the state AFTER moving an item to row 1 (between header and games)
|
||||
// calculateSortOrder expects the moved item already in the array
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate), // Row 0
|
||||
.customItem(customItem), // Row 1 - the moved item (before games)
|
||||
.games([game], dayNumber: 1) // Row 2
|
||||
]
|
||||
|
||||
// Calculate sortOrder for the item at row 1
|
||||
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
|
||||
|
||||
// With no other movable items in before-games region, should get -1.0
|
||||
XCTAssertLessThan(sortOrder, 0, "First item before games should get negative sortOrder")
|
||||
XCTAssertEqual(sortOrder, -1.0, "With no neighbors in before-games region, should return -1.0")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: Coordinate Space Conversion
|
||||
|
||||
/// proposedToOriginal converts correctly when proposed < sourceRow.
|
||||
func test_proposedToOriginal_belowSource_unchanged() {
|
||||
// Source at row 5, proposed is 3
|
||||
// After removing row 5, proposed 3 is still original 3
|
||||
let result = Logic.proposedToOriginal(3, sourceRow: 5)
|
||||
XCTAssertEqual(result, 3)
|
||||
}
|
||||
|
||||
/// proposedToOriginal converts correctly when proposed >= sourceRow.
|
||||
func test_proposedToOriginal_atOrAboveSource_addOne() {
|
||||
// Source at row 5, proposed is 5
|
||||
// After removing row 5, proposed 5 corresponds to original 6
|
||||
let result = Logic.proposedToOriginal(5, sourceRow: 5)
|
||||
XCTAssertEqual(result, 6)
|
||||
|
||||
// Source at row 5, proposed is 7
|
||||
let result2 = Logic.proposedToOriginal(7, sourceRow: 5)
|
||||
XCTAssertEqual(result2, 8)
|
||||
}
|
||||
|
||||
/// originalToProposed converts correctly.
|
||||
func test_originalToProposed_convertsCorrectly() {
|
||||
// Original 3 with source at 5 -> proposed 3 (below source)
|
||||
XCTAssertEqual(Logic.originalToProposed(3, sourceRow: 5), 3)
|
||||
|
||||
// Original 7 with source at 5 -> proposed 6 (above source)
|
||||
XCTAssertEqual(Logic.originalToProposed(7, sourceRow: 5), 6)
|
||||
|
||||
// Original 5 with source at 5 -> nil (is the source)
|
||||
XCTAssertNil(Logic.originalToProposed(5, sourceRow: 5))
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: DragZones Coordinate Space
|
||||
|
||||
/// DragZones validDropRows should be in ORIGINAL coordinate space.
|
||||
func test_dragZones_returnsOriginalCoordinates() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity")
|
||||
|
||||
// [0: header, 1: games, 2: customItem]
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.games([game], dayNumber: 1),
|
||||
.customItem(customItem)
|
||||
]
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 1, items: [])
|
||||
|
||||
let zones = Logic.calculateCustomItemDragZones(
|
||||
item: customItem,
|
||||
sourceRow: 2,
|
||||
flatItems: items,
|
||||
constraints: constraints,
|
||||
findTravelSortOrder: { _ in nil }
|
||||
)
|
||||
|
||||
// Source row (2) should NOT be in invalidRowIndices (it's being dragged)
|
||||
XCTAssertFalse(zones.invalidRowIndices.contains(2),
|
||||
"Source row should not be in invalidRowIndices")
|
||||
|
||||
// Row 0 (header) should be invalid
|
||||
XCTAssertTrue(zones.invalidRowIndices.contains(0),
|
||||
"Day header should be invalid")
|
||||
|
||||
// validDropRows are in ORIGINAL coordinate space
|
||||
// After removing row 2, post-removal array is [header, games] with count 2
|
||||
// Proposed indices: 0 (before header - invalid), 1 (after header/games), 2 (append at end)
|
||||
// - Proposed 1 -> original 1 (below source 2)
|
||||
// - Proposed 2 -> original 3 (at/above source, so +1)
|
||||
// Note: original index 3 is valid for append operations (insert at end)
|
||||
for validRow in zones.validDropRows {
|
||||
// Valid rows must be <= items.count (allowing append at end which is items.count)
|
||||
XCTAssertLessThanOrEqual(validRow, items.count,
|
||||
"Valid drop rows must be valid indices for insertion (including append)")
|
||||
// Source row should not be included
|
||||
XCTAssertNotEqual(validRow, 2,
|
||||
"Valid drop rows should not include source row itself")
|
||||
}
|
||||
}
|
||||
|
||||
/// DragZones invalidRowIndices should not include sourceRow.
|
||||
func test_dragZones_excludesSourceRowFromInvalid() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.travel(travel, dayNumber: 1), // Source row 1
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
|
||||
]
|
||||
|
||||
let zones = Logic.calculateTravelDragZones(
|
||||
segment: travel,
|
||||
sourceRow: 1,
|
||||
flatItems: items,
|
||||
travelValidRanges: [:],
|
||||
constraints: nil,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { segment in
|
||||
let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name)
|
||||
return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .travel(info))
|
||||
},
|
||||
findTravelSortOrder: { _ in 1.0 }
|
||||
)
|
||||
|
||||
XCTAssertFalse(zones.invalidRowIndices.contains(1),
|
||||
"Source row (being dragged) should not be marked invalid")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: computeValidDestinationRowsProposed End Position
|
||||
|
||||
/// Verify that append-at-end is handled correctly.
|
||||
func test_computeValidDestinationRows_includesAppendAtEnd() {
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity")
|
||||
|
||||
// [0: header, 1: customItem]
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.customItem(customItem)
|
||||
]
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 1, items: [])
|
||||
|
||||
let validRows = Logic.computeValidDestinationRowsProposed(
|
||||
flatItems: items,
|
||||
sourceRow: 1,
|
||||
dragged: .customItem(customItem),
|
||||
travelValidRanges: [:],
|
||||
constraints: constraints,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { _ in fatalError() },
|
||||
findTravelSortOrder: { _ in nil }
|
||||
)
|
||||
|
||||
// After removing row 1, array is [header] with count 1
|
||||
// Valid proposed positions: 1 (after header, which is append-at-end)
|
||||
// maxProposed = items.count - 1 = 1
|
||||
// So we test 1...1 which includes the append position
|
||||
|
||||
// The proposed row 1 should be valid (append after header)
|
||||
// But wait - we need to check what simulateMove does:
|
||||
// simulateMove with count=1 (after removal), proposed=1 -> clampedDest=1 which is insert at end
|
||||
// This inserts AFTER the header, which is valid
|
||||
|
||||
XCTAssertTrue(validRows.contains(1),
|
||||
"Proposed row 1 (append after header) should be valid")
|
||||
}
|
||||
}
|
||||
254
SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift
Normal file
254
SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
125
SportsTimeTests/Features/Trip/ItineraryTestHelpers.swift
Normal file
125
SportsTimeTests/Features/Trip/ItineraryTestHelpers.swift
Normal file
@@ -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..<count).map { i in
|
||||
ItineraryDayData(
|
||||
id: i + 1,
|
||||
dayNumber: i + 1,
|
||||
date: Calendar.current.date(byAdding: .day, value: i, to: baseDate)!,
|
||||
games: [],
|
||||
items: [],
|
||||
travelBefore: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func dayAfter(_ date: Date) -> 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: "🍽️"))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user