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:
Trey t
2026-01-17 21:05:58 -06:00
parent d14976812a
commit 12c2de8a1b
2 changed files with 380 additions and 0 deletions

View 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
}
}
}