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
|
||||
}
|
||||
}
|
||||
}
|
||||
247
SportsTimeTests/ItineraryConstraintsTests.swift
Normal file
247
SportsTimeTests/ItineraryConstraintsTests.swift
Normal file
@@ -0,0 +1,247 @@
|
||||
import XCTest
|
||||
@testable import SportsTime
|
||||
|
||||
final class ItineraryConstraintsTests: XCTestCase {
|
||||
|
||||
// MARK: - Custom Item Tests (No Constraints)
|
||||
|
||||
func test_customItem_canGoOnAnyDay() {
|
||||
// Given: A 5-day trip with games on days 1 and 5
|
||||
let constraints = makeConstraints(tripDays: 5, gameDays: [1, 5])
|
||||
let customItem = makeCustomItem(day: 1, sortOrder: 50)
|
||||
|
||||
// When/Then: Custom item can go on any day
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 1, sortOrder: 50))
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 50))
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 3, sortOrder: 50))
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 4, sortOrder: 50))
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 5, sortOrder: 50))
|
||||
}
|
||||
|
||||
func test_customItem_canGoBeforeOrAfterGames() {
|
||||
// Given: A day with a game at sortOrder 100
|
||||
let constraints = makeConstraints(tripDays: 3, gameDays: [2])
|
||||
let customItem = makeCustomItem(day: 2, sortOrder: 50)
|
||||
|
||||
// When/Then: Custom item can go before or after game
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 50)) // Before
|
||||
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 150)) // After
|
||||
}
|
||||
|
||||
// MARK: - Travel Constraint Tests
|
||||
|
||||
func test_travel_validDayRange_simpleCase() {
|
||||
// Given: Chicago game Day 1, Detroit game Day 3
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 5,
|
||||
games: [
|
||||
makeGameItem(city: "Chicago", day: 1),
|
||||
makeGameItem(city: "Detroit", day: 3)
|
||||
]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 200)
|
||||
|
||||
// When
|
||||
let range = constraints.validDayRange(for: travel)
|
||||
|
||||
// Then: Travel can be on days 1 (after game), 2, or 3 (before game)
|
||||
XCTAssertEqual(range, 1...3)
|
||||
}
|
||||
|
||||
func test_travel_mustBeAfterDepartureGames() {
|
||||
// Given: Chicago game on Day 1 at sortOrder 100
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 3,
|
||||
games: [makeGameItem(city: "Chicago", day: 1, sortOrder: 100)]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50)
|
||||
|
||||
// When/Then: Travel before game is invalid
|
||||
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50))
|
||||
|
||||
// Travel after game is valid
|
||||
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150))
|
||||
}
|
||||
|
||||
func test_travel_mustBeBeforeArrivalGames() {
|
||||
// Given: Detroit game on Day 3 at sortOrder 100
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 3,
|
||||
games: [makeGameItem(city: "Detroit", day: 3, sortOrder: 100)]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 150)
|
||||
|
||||
// When/Then: Travel after arrival game is invalid
|
||||
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 150))
|
||||
|
||||
// Travel before game is valid
|
||||
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
|
||||
}
|
||||
|
||||
func test_travel_canBeAnywhereOnRestDays() {
|
||||
// Given: Chicago game Day 1, Detroit game Day 4, rest days 2-3
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 4,
|
||||
games: [
|
||||
makeGameItem(city: "Chicago", day: 1),
|
||||
makeGameItem(city: "Detroit", day: 4)
|
||||
]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 50)
|
||||
|
||||
// When/Then: Any position on rest days is valid
|
||||
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 1))
|
||||
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 100))
|
||||
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 500))
|
||||
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
|
||||
}
|
||||
|
||||
func test_travel_mustBeAfterAllDepartureGamesOnSameDay() {
|
||||
// Given: Two games in Chicago on Day 1 (1pm and 7pm)
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 3,
|
||||
games: [
|
||||
makeGameItem(city: "Chicago", day: 1, sortOrder: 100), // 1pm
|
||||
makeGameItem(city: "Chicago", day: 1, sortOrder: 101) // 7pm
|
||||
]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 100.5)
|
||||
|
||||
// When/Then: Between games is invalid
|
||||
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 100.5))
|
||||
|
||||
// After all games is valid
|
||||
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150))
|
||||
}
|
||||
|
||||
func test_travel_mustBeBeforeAllArrivalGamesOnSameDay() {
|
||||
// Given: Two games in Detroit on Day 3 (1pm and 7pm)
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 3,
|
||||
games: [
|
||||
makeGameItem(city: "Detroit", day: 3, sortOrder: 100), // 1pm
|
||||
makeGameItem(city: "Detroit", day: 3, sortOrder: 101) // 7pm
|
||||
]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 100.5)
|
||||
|
||||
// When/Then: Between games is invalid
|
||||
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 100.5))
|
||||
|
||||
// Before all games is valid
|
||||
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
|
||||
}
|
||||
|
||||
func test_travel_cannotGoOutsideValidDayRange() {
|
||||
// Given: Chicago game Day 2, Detroit game Day 4
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 5,
|
||||
games: [
|
||||
makeGameItem(city: "Chicago", day: 2),
|
||||
makeGameItem(city: "Detroit", day: 4)
|
||||
]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50)
|
||||
|
||||
// When/Then: Day 1 (before departure game) is invalid
|
||||
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50))
|
||||
|
||||
// Day 5 (after arrival game) is invalid
|
||||
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 5, sortOrder: 50))
|
||||
}
|
||||
|
||||
// MARK: - Game Immutability Tests
|
||||
|
||||
func test_gameItem_cannotBeMoved() {
|
||||
// Given: A game item
|
||||
let gameItem = makeGameItem(city: "Chicago", day: 2)
|
||||
let constraints = makeConstraints(tripDays: 5, games: [gameItem])
|
||||
|
||||
// When/Then: Game items should never be valid for any position
|
||||
XCTAssertFalse(constraints.isValidPosition(for: gameItem, day: 2, sortOrder: 100))
|
||||
XCTAssertFalse(constraints.isValidPosition(for: gameItem, day: 3, sortOrder: 100))
|
||||
XCTAssertFalse(constraints.isValidPosition(for: gameItem, day: 1, sortOrder: 50))
|
||||
}
|
||||
|
||||
// MARK: - Edge Case Tests
|
||||
|
||||
func test_travel_validDayRange_returnsNil_whenConstraintsImpossible() {
|
||||
// Given: Departure game on Day 3, Arrival game on Day 1 (reversed order)
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 3,
|
||||
games: [
|
||||
makeGameItem(city: "Chicago", day: 3), // Must depart AFTER day 3
|
||||
makeGameItem(city: "Detroit", day: 1) // Must arrive BEFORE day 1
|
||||
]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 50)
|
||||
|
||||
// When/Then: No valid range exists (impossible constraints)
|
||||
XCTAssertNil(constraints.validDayRange(for: travel))
|
||||
}
|
||||
|
||||
// MARK: - Barrier Games Tests
|
||||
|
||||
func test_barrierGames_returnsDepartureAndArrivalGames() {
|
||||
// Given: Chicago games Days 1-2, Detroit games Days 4-5
|
||||
let chicagoGame1 = makeGameItem(city: "Chicago", day: 1, sortOrder: 100)
|
||||
let chicagoGame2 = makeGameItem(city: "Chicago", day: 2, sortOrder: 100)
|
||||
let detroitGame1 = makeGameItem(city: "Detroit", day: 4, sortOrder: 100)
|
||||
let detroitGame2 = makeGameItem(city: "Detroit", day: 5, sortOrder: 100)
|
||||
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 5,
|
||||
games: [chicagoGame1, chicagoGame2, detroitGame1, detroitGame2]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50)
|
||||
|
||||
// When
|
||||
let barriers = constraints.barrierGames(for: travel)
|
||||
|
||||
// Then: Returns the last Chicago game and first Detroit game
|
||||
XCTAssertEqual(barriers.count, 2)
|
||||
XCTAssertTrue(barriers.contains { $0.id == chicagoGame2.id })
|
||||
XCTAssertTrue(barriers.contains { $0.id == detroitGame1.id })
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private let testTripId = UUID()
|
||||
|
||||
private func makeConstraints(tripDays: Int, gameDays: [Int] = []) -> ItineraryConstraints {
|
||||
let games = gameDays.map { makeGameItem(city: "TestCity", day: $0) }
|
||||
return makeConstraints(tripDays: tripDays, games: games)
|
||||
}
|
||||
|
||||
private func makeConstraints(tripDays: Int, games: [ItineraryItem]) -> ItineraryConstraints {
|
||||
return ItineraryConstraints(tripDayCount: tripDays, items: games)
|
||||
}
|
||||
|
||||
private func makeGameItem(city: String, day: Int, sortOrder: Double = 100) -> ItineraryItem {
|
||||
// For tests, we use gameId to encode the city
|
||||
return ItineraryItem(
|
||||
tripId: testTripId,
|
||||
day: day,
|
||||
sortOrder: sortOrder,
|
||||
kind: .game(gameId: "game-\(city)-\(UUID().uuidString.prefix(4))")
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTravelItem(from: String, to: String, day: Int, sortOrder: Double) -> ItineraryItem {
|
||||
return ItineraryItem(
|
||||
tripId: testTripId,
|
||||
day: day,
|
||||
sortOrder: sortOrder,
|
||||
kind: .travel(TravelInfo(fromCity: from, toCity: to))
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCustomItem(day: Int, sortOrder: Double) -> ItineraryItem {
|
||||
return ItineraryItem(
|
||||
tripId: testTripId,
|
||||
day: day,
|
||||
sortOrder: sortOrder,
|
||||
kind: .custom(CustomInfo(title: "Test Item", icon: "star"))
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user