diff --git a/SportsTime/Core/Models/Domain/ItineraryConstraints.swift b/SportsTime/Core/Models/Domain/ItineraryConstraints.swift new file mode 100644 index 0000000..98a48eb --- /dev/null +++ b/SportsTime/Core/Models/Domain/ItineraryConstraints.swift @@ -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? { + 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 + } + } +} diff --git a/SportsTimeTests/ItineraryConstraintsTests.swift b/SportsTimeTests/ItineraryConstraintsTests.swift new file mode 100644 index 0000000..ed72912 --- /dev/null +++ b/SportsTimeTests/ItineraryConstraintsTests.swift @@ -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")) + ) + } +}