feat: add ItineraryConstraints with full test coverage
Validates travel positions based on game locations: - Travel must be after ALL departure city games - Travel must be before ALL arrival city games - Custom items have no constraints - Games are fixed (cannot be moved) 12 tests covering all constraint scenarios including edge cases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
133
SportsTime/Core/Models/Domain/ItineraryConstraints.swift
Normal file
133
SportsTime/Core/Models/Domain/ItineraryConstraints.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
/// Validates itinerary item positions and calculates valid drop zones
|
||||
struct ItineraryConstraints {
|
||||
let tripDayCount: Int
|
||||
private let items: [ItineraryItem]
|
||||
|
||||
/// City extracted from game ID (format: "game-CityName-xxxx")
|
||||
private func city(forGameId gameId: String) -> String? {
|
||||
let components = gameId.components(separatedBy: "-")
|
||||
guard components.count >= 2 else { return nil }
|
||||
return components[1]
|
||||
}
|
||||
|
||||
init(tripDayCount: Int, items: [ItineraryItem]) {
|
||||
self.tripDayCount = tripDayCount
|
||||
self.items = items
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Check if a position is valid for an item
|
||||
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
|
||||
// Day must be within trip range
|
||||
guard day >= 1 && day <= tripDayCount else { return false }
|
||||
|
||||
switch item.kind {
|
||||
case .game:
|
||||
// Games are fixed, should never be moved
|
||||
return false
|
||||
|
||||
case .travel(let info):
|
||||
return isValidTravelPosition(
|
||||
fromCity: info.fromCity,
|
||||
toCity: info.toCity,
|
||||
day: day,
|
||||
sortOrder: sortOrder
|
||||
)
|
||||
|
||||
case .custom:
|
||||
// Custom items can go anywhere
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the valid day range for a travel item
|
||||
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>? {
|
||||
guard case .travel(let info) = item.kind else { return nil }
|
||||
|
||||
let departureGameDays = gameDays(in: info.fromCity)
|
||||
let arrivalGameDays = gameDays(in: info.toCity)
|
||||
|
||||
// Can leave on or after the day of last departure game
|
||||
let minDay = departureGameDays.max() ?? 1
|
||||
// Must arrive on or before the day of first arrival game
|
||||
let maxDay = arrivalGameDays.min() ?? tripDayCount
|
||||
|
||||
guard minDay <= maxDay else { return nil }
|
||||
return minDay...maxDay
|
||||
}
|
||||
|
||||
/// Get the games that act as barriers for a travel item (for visual highlighting)
|
||||
func barrierGames(for item: ItineraryItem) -> [ItineraryItem] {
|
||||
guard case .travel(let info) = item.kind else { return [] }
|
||||
|
||||
var barriers: [ItineraryItem] = []
|
||||
|
||||
// Last game in departure city
|
||||
let departureGames = games(in: info.fromCity).sorted { $0.day < $1.day || ($0.day == $1.day && $0.sortOrder < $1.sortOrder) }
|
||||
if let lastDeparture = departureGames.last {
|
||||
barriers.append(lastDeparture)
|
||||
}
|
||||
|
||||
// First game in arrival city
|
||||
let arrivalGames = games(in: info.toCity).sorted { $0.day < $1.day || ($0.day == $1.day && $0.sortOrder < $1.sortOrder) }
|
||||
if let firstArrival = arrivalGames.first {
|
||||
barriers.append(firstArrival)
|
||||
}
|
||||
|
||||
return barriers
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func isValidTravelPosition(fromCity: String, toCity: String, day: Int, sortOrder: Double) -> Bool {
|
||||
let departureGameDays = gameDays(in: fromCity)
|
||||
let arrivalGameDays = gameDays(in: toCity)
|
||||
|
||||
let minDay = departureGameDays.max() ?? 1
|
||||
let maxDay = arrivalGameDays.min() ?? tripDayCount
|
||||
|
||||
// Check day is in valid range
|
||||
guard day >= minDay && day <= maxDay else { return false }
|
||||
|
||||
// Check sortOrder constraints on edge days
|
||||
if departureGameDays.contains(day) {
|
||||
// On a departure game day: must be after ALL games in that city on that day
|
||||
let maxGameSortOrder = games(in: fromCity)
|
||||
.filter { $0.day == day }
|
||||
.map { $0.sortOrder }
|
||||
.max() ?? 0
|
||||
|
||||
if sortOrder <= maxGameSortOrder {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if arrivalGameDays.contains(day) {
|
||||
// On an arrival game day: must be before ALL games in that city on that day
|
||||
let minGameSortOrder = games(in: toCity)
|
||||
.filter { $0.day == day }
|
||||
.map { $0.sortOrder }
|
||||
.min() ?? Double.greatestFiniteMagnitude
|
||||
|
||||
if sortOrder >= minGameSortOrder {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func gameDays(in city: String) -> [Int] {
|
||||
return games(in: city).map { $0.day }
|
||||
}
|
||||
|
||||
private func games(in city: String) -> [ItineraryItem] {
|
||||
return items.filter { item in
|
||||
guard case .game(let gameId) = item.kind else { return false }
|
||||
return self.city(forGameId: gameId) == city
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user