Compare commits
12 Commits
ce734b6c63
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c647d37e | ||
|
|
a77a93b92d | ||
|
|
a6f538dfed | ||
|
|
9b622f8bbb | ||
|
|
188076717b | ||
|
|
0fa3db5401 | ||
|
|
87b9971714 | ||
|
|
aa6477b886 | ||
|
|
65fbb596a8 | ||
|
|
741924f6fc | ||
|
|
6cbcef47ae | ||
|
|
db6ab2f923 |
@@ -22,6 +22,6 @@ This file is for AI/code agents working in this repo.
|
|||||||
|
|
||||||
- Run the touched test class.
|
- Run the touched test class.
|
||||||
- Run full UI suite:
|
- Run full UI suite:
|
||||||
- `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests`
|
- `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -parallel-testing-enabled NO -only-testing:SportsTimeUITests`
|
||||||
- Run full scheme verification if behavior touched shared flows:
|
- Run full scheme verification if behavior touched shared flows:
|
||||||
- `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO`
|
- `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -parallel-testing-enabled NO`
|
||||||
|
|||||||
50
CLAUDE.md
50
CLAUDE.md
@@ -6,25 +6,65 @@ iOS app for planning multi-stop sports road trips. Offline-first architecture wi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the iOS app
|
# Build the iOS app
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' build
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' test
|
||||||
|
|
||||||
# Run specific test suite
|
# Run specific test suite
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripPlanningEngineTests test
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:SportsTimeTests/TripPlanningEngineTests test
|
||||||
|
|
||||||
# Run a single test
|
# Run a single test
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripPlanningEngineTests/planningMode_dateRange test
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:SportsTimeTests/TripPlanningEngineTests/planningMode_dateRange test
|
||||||
|
|
||||||
# Run UI tests only
|
# Run UI tests only
|
||||||
xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests
|
xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -parallel-testing-enabled NO -only-testing:SportsTimeUITests
|
||||||
|
|
||||||
# Data scraping (Python)
|
# Data scraping (Python)
|
||||||
cd Scripts && pip install -r requirements.txt
|
cd Scripts && pip install -r requirements.txt
|
||||||
python scrape_schedules.py --sport all --season 2026
|
python scrape_schedules.py --sport all --season 2026
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Deploy to App Store Connect
|
||||||
|
|
||||||
|
Use the `/deploy` command (e.g., `/deploy main`) to archive and upload to TestFlight. If `/deploy` is unavailable, follow these steps manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Source secrets
|
||||||
|
source ~/Desktop/code/tony-scripts/tony.env
|
||||||
|
|
||||||
|
# 2. Checkout and pull
|
||||||
|
cd SportsTime
|
||||||
|
git fetch origin && git checkout <branch> && git pull origin <branch>
|
||||||
|
|
||||||
|
# 3. Unlock keychain for headless signing
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db
|
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db
|
||||||
|
|
||||||
|
# 4. Archive (do NOT override build number — App Store Connect auto-increments)
|
||||||
|
xcodebuild archive \
|
||||||
|
-project SportsTime.xcodeproj \
|
||||||
|
-scheme SportsTime \
|
||||||
|
-archivePath ~/reports/SportsTime.xcarchive \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-allowProvisioningUpdates
|
||||||
|
|
||||||
|
# 6. Export and upload directly (auto-increments build number)
|
||||||
|
xcodebuild -exportArchive \
|
||||||
|
-archivePath ~/reports/SportsTime.xcarchive \
|
||||||
|
-exportPath ~/reports/export \
|
||||||
|
-exportOptionsPlist ~/Desktop/code/tony-scripts/ExportOptions-SportsTime-Upload.plist \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
-authenticationKeyPath ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8 \
|
||||||
|
-authenticationKeyID "$ASC_KEY_ID" \
|
||||||
|
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||||
|
|
||||||
|
# 8. Clean up
|
||||||
|
rm -rf ~/reports/SportsTime.xcarchive ~/reports/export
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always confirm with the user before deploying.** Report version, build number, and commit before starting the archive.
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
- **Pattern**: Clean MVVM with feature-based modules
|
- **Pattern**: Clean MVVM with feature-based modules
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ All schedule data flows through `AppDataProvider.shared` - never access CloudKit
|
|||||||
```bash
|
```bash
|
||||||
xcodebuild -project SportsTime.xcodeproj \
|
xcodebuild -project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
build
|
build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -116,13 +116,13 @@ xcodebuild -project SportsTime.xcodeproj \
|
|||||||
# All tests
|
# All tests
|
||||||
xcodebuild -project SportsTime.xcodeproj \
|
xcodebuild -project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
test
|
test
|
||||||
|
|
||||||
# Specific test suite
|
# Specific test suite
|
||||||
xcodebuild -project SportsTime.xcodeproj \
|
xcodebuild -project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-only-testing:SportsTimeTests/EdgeCaseTests \
|
-only-testing:SportsTimeTests/EdgeCaseTests \
|
||||||
test
|
test
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ xcodebuild -project SportsTime.xcodeproj \
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO \
|
-parallel-testing-enabled NO \
|
||||||
-only-testing:SportsTimeUITests
|
-only-testing:SportsTimeUITests
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ final class AnalyticsManager {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
return "phc_development_key" // Safe fallback for debug builds
|
return "phc_development_key" // Safe fallback for debug builds
|
||||||
#else
|
#else
|
||||||
fatalError("Missing POSTHOG_API_KEY in Info.plist")
|
return "phc_RnF7XWdPeAY1M8ABAK75KlrOGVFfqHtZbkUuZ7oY8Xm"
|
||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
private static let host = "https://analytics.88oakapps.com"
|
private static let host = "https://analytics.88oakapps.com"
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ struct TravelSegment: Identifiable, Codable, Hashable {
|
|||||||
var estimatedDrivingHours: Double { durationHours }
|
var estimatedDrivingHours: Double { durationHours }
|
||||||
var estimatedDistanceMiles: Double { distanceMiles }
|
var estimatedDistanceMiles: Double { distanceMiles }
|
||||||
|
|
||||||
|
/// Whether this segment requires an overnight stop based on driving time.
|
||||||
|
/// Segments over 8 hours of driving require rest.
|
||||||
|
var requiresOvernightStop: Bool {
|
||||||
|
durationHours > 8.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of travel days this segment spans (accounting for daily driving limits).
|
||||||
|
func travelDays(maxDailyHours: Double = 8.0) -> Int {
|
||||||
|
max(1, Int(ceil(durationHours / maxDailyHours)))
|
||||||
|
}
|
||||||
|
|
||||||
var formattedDistance: String {
|
var formattedDistance: String {
|
||||||
String(format: "%.0f mi", distanceMiles)
|
String(format: "%.0f mi", distanceMiles)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,7 +371,9 @@ final class CanonicalSyncService {
|
|||||||
SyncLogger.shared.log("⚠️ [SYNC] Failed to save error state: \(saveError.localizedDescription)")
|
SyncLogger.shared.log("⚠️ [SYNC] Failed to save error state: \(saveError.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
SyncStatusMonitor.shared.syncFailed(error: error)
|
SyncStatusMonitor.shared.syncFailed(error: error)
|
||||||
|
#endif
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,46 @@
|
|||||||
// RouteDescriptionGenerator.swift
|
// RouteDescriptionGenerator.swift
|
||||||
// SportsTime
|
// SportsTime
|
||||||
//
|
//
|
||||||
// On-device AI route description generation using Foundation Models
|
// Route description generation — uses fast templates by default,
|
||||||
|
// with optional on-device AI enhancement via FoundationModels.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import FoundationModels
|
import FoundationModels
|
||||||
|
|
||||||
/// Generates human-readable route descriptions using on-device AI
|
/// Generates human-readable route descriptions for trip option cards.
|
||||||
|
///
|
||||||
|
/// Uses instant template-based descriptions by default. When FoundationModels
|
||||||
|
/// is available, progressively enhances descriptions with on-device AI in the
|
||||||
|
/// background (serialized to avoid rate limiting).
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class RouteDescriptionGenerator {
|
final class RouteDescriptionGenerator {
|
||||||
static let shared = RouteDescriptionGenerator()
|
static let shared = RouteDescriptionGenerator()
|
||||||
|
|
||||||
private(set) var isAvailable = false
|
private(set) var isAvailable = false
|
||||||
|
|
||||||
|
// Cache AI-generated descriptions by option ID
|
||||||
|
private var aiCache: [UUID: String] = [:]
|
||||||
|
|
||||||
|
private var consecutiveFailures = 0
|
||||||
|
private let maxConsecutiveFailures = 3
|
||||||
|
|
||||||
|
private var pendingRequests: [(id: UUID, input: RouteDescriptionInput, continuation: CheckedContinuation<String?, Never>)] = []
|
||||||
|
private var isProcessing = false
|
||||||
|
|
||||||
|
/// Reused session — avoids per-request session creation overhead.
|
||||||
|
/// Reset only on context overflow.
|
||||||
private var session: LanguageModelSession?
|
private var session: LanguageModelSession?
|
||||||
|
|
||||||
// Cache generated descriptions by option ID
|
private static let instructions = """
|
||||||
private var cache: [UUID: String] = [:]
|
You write 1-sentence sports road trip descriptions. Exciting, concise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let generationOptions = GenerationOptions(
|
||||||
|
sampling: .greedy,
|
||||||
|
maximumResponseTokens: 60
|
||||||
|
)
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
checkAvailability()
|
checkAvailability()
|
||||||
@@ -26,91 +49,210 @@ final class RouteDescriptionGenerator {
|
|||||||
|
|
||||||
private func checkAvailability() {
|
private func checkAvailability() {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
// Keep simulator behavior deterministic and avoid known FoundationModels simulator crashes.
|
|
||||||
isAvailable = false
|
isAvailable = false
|
||||||
session = nil
|
|
||||||
#else
|
#else
|
||||||
switch SystemLanguageModel.default.availability {
|
switch SystemLanguageModel.default.availability {
|
||||||
case .available:
|
case .available:
|
||||||
isAvailable = true
|
isAvailable = true
|
||||||
session = LanguageModelSession(instructions: """
|
session = LanguageModelSession(instructions: Self.instructions)
|
||||||
You are a travel copywriter creating exciting, brief descriptions for sports road trips.
|
|
||||||
Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
|
|
||||||
Keep descriptions to 1-2 short sentences maximum.
|
|
||||||
""")
|
|
||||||
case .unavailable:
|
case .unavailable:
|
||||||
isAvailable = false
|
isAvailable = false
|
||||||
session = nil
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a brief, exciting description for a route option
|
/// Pre-load model weights and cache the instruction prefix.
|
||||||
func generateDescription(for option: RouteDescriptionInput) async -> String? {
|
/// Call this when entering the trip options view.
|
||||||
// Check cache first
|
func prewarm() {
|
||||||
if let cached = cache[option.id] {
|
guard isAvailable, let session else { return }
|
||||||
|
Task { try? await session.prewarm(promptPrefix: Prompt(Self.instructions)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fast Template Descriptions (instant)
|
||||||
|
|
||||||
|
/// Returns an instant template-based description. No async, no AI.
|
||||||
|
func templateDescription(for input: RouteDescriptionInput) -> String {
|
||||||
|
let cityCount = input.cities.count
|
||||||
|
let gameCount = input.totalGames
|
||||||
|
let miles = Int(input.totalMiles)
|
||||||
|
let sports = input.sports
|
||||||
|
|
||||||
|
if cityCount == 1 {
|
||||||
|
let city = input.cities.first ?? "one city"
|
||||||
|
if gameCount == 1 {
|
||||||
|
return "A single-game stop in \(city)."
|
||||||
|
}
|
||||||
|
return "Catch \(gameCount) games in \(city) — no driving needed."
|
||||||
|
}
|
||||||
|
|
||||||
|
let sportText: String
|
||||||
|
if sports.count == 1 {
|
||||||
|
sportText = sports.first ?? "sports"
|
||||||
|
} else {
|
||||||
|
sportText = "\(sports.count)-sport"
|
||||||
|
}
|
||||||
|
|
||||||
|
let milesText = miles > 0 ? " across \(miles) miles" : ""
|
||||||
|
let drivingText: String
|
||||||
|
if input.totalDrivingHours > 0 {
|
||||||
|
let hours = Int(input.totalDrivingHours)
|
||||||
|
drivingText = " (\(hours)h driving)"
|
||||||
|
} else {
|
||||||
|
drivingText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let templates: [String]
|
||||||
|
if cityCount <= 2 {
|
||||||
|
templates = [
|
||||||
|
"\(gameCount) \(sportText) games in \(cityCount) cities\(milesText)\(drivingText).",
|
||||||
|
"A \(cityCount)-city \(sportText) trip with \(gameCount) games\(milesText).",
|
||||||
|
]
|
||||||
|
} else if cityCount <= 4 {
|
||||||
|
templates = [
|
||||||
|
"Hit \(cityCount) cities for \(gameCount) \(sportText) games\(milesText)\(drivingText).",
|
||||||
|
"A \(cityCount)-city \(sportText) road trip — \(gameCount) games\(milesText).",
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
templates = [
|
||||||
|
"Epic \(cityCount)-city tour: \(gameCount) \(sportText) games\(milesText)\(drivingText).",
|
||||||
|
"The grand tour — \(cityCount) cities, \(gameCount) games\(milesText).",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = abs(input.id.hashValue) % templates.count
|
||||||
|
return templates[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Enhancement (progressive, serialized)
|
||||||
|
|
||||||
|
/// Request an AI-enhanced description. Returns cached result or nil.
|
||||||
|
func aiDescription(for id: UUID) -> String? {
|
||||||
|
aiCache[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue an AI description generation. Serialized to avoid rate limiting.
|
||||||
|
func requestAIDescription(for input: RouteDescriptionInput) async -> String? {
|
||||||
|
if let cached = aiCache[input.id] {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
guard isAvailable, let session = session else {
|
guard isAvailable else { return nil }
|
||||||
return nil
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
pendingRequests.append((id: input.id, input: input, continuation: continuation))
|
||||||
|
processNextIfIdle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let prompt = buildPrompt(for: option)
|
private func processNextIfIdle() {
|
||||||
|
guard !isProcessing, !pendingRequests.isEmpty, isAvailable else { return }
|
||||||
|
isProcessing = true
|
||||||
|
|
||||||
|
let request = pendingRequests.removeFirst()
|
||||||
|
|
||||||
|
if let cached = aiCache[request.id] {
|
||||||
|
request.continuation.resume(returning: cached)
|
||||||
|
isProcessing = false
|
||||||
|
processNextIfIdle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let result = await performGeneration(for: request.input)
|
||||||
|
request.continuation.resume(returning: result)
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
isProcessing = false
|
||||||
|
processNextIfIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performGeneration(for input: RouteDescriptionInput) async -> String? {
|
||||||
|
let prompt = buildPrompt(for: input)
|
||||||
|
|
||||||
|
// Ensure we have a session
|
||||||
|
if session == nil {
|
||||||
|
session = LanguageModelSession(instructions: Self.instructions)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let session else { return nil }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await session.respond(
|
let response = try await session.respond(
|
||||||
to: prompt,
|
to: prompt,
|
||||||
generating: RouteDescription.self
|
generating: RouteDescription.self,
|
||||||
|
options: Self.generationOptions
|
||||||
)
|
)
|
||||||
|
|
||||||
let description = response.content.description
|
let description = response.content.description
|
||||||
cache[option.id] = description
|
aiCache[input.id] = description
|
||||||
|
consecutiveFailures = 0
|
||||||
return description
|
return description
|
||||||
|
|
||||||
} catch LanguageModelSession.GenerationError.guardrailViolation {
|
} catch let error as LanguageModelSession.GenerationError {
|
||||||
return nil
|
switch error {
|
||||||
} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
|
case .rateLimited:
|
||||||
// Reset session if context exceeded
|
try? await Task.sleep(for: .seconds(2))
|
||||||
self.session = LanguageModelSession(instructions: """
|
do {
|
||||||
You are a travel copywriter creating exciting, brief descriptions for sports road trips.
|
let response = try await session.respond(
|
||||||
Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
|
to: prompt,
|
||||||
Keep descriptions to 1-2 short sentences maximum.
|
generating: RouteDescription.self,
|
||||||
""")
|
options: Self.generationOptions
|
||||||
return nil
|
)
|
||||||
|
let description = response.content.description
|
||||||
|
aiCache[input.id] = description
|
||||||
|
consecutiveFailures = 0
|
||||||
|
return description
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
case .exceededContextWindowSize:
|
||||||
|
// Reset session and retry once
|
||||||
|
self.session = LanguageModelSession(instructions: Self.instructions)
|
||||||
|
do {
|
||||||
|
let response = try await self.session!.respond(
|
||||||
|
to: prompt,
|
||||||
|
generating: RouteDescription.self,
|
||||||
|
options: Self.generationOptions
|
||||||
|
)
|
||||||
|
let description = response.content.description
|
||||||
|
aiCache[input.id] = description
|
||||||
|
consecutiveFailures = 0
|
||||||
|
return description
|
||||||
|
} catch {
|
||||||
|
recordFailure()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
recordFailure()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
recordFailure()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordFailure() {
|
||||||
|
consecutiveFailures += 1
|
||||||
|
if consecutiveFailures >= maxConsecutiveFailures {
|
||||||
|
isAvailable = false
|
||||||
|
for pending in pendingRequests {
|
||||||
|
pending.continuation.resume(returning: nil)
|
||||||
|
}
|
||||||
|
pendingRequests.removeAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildPrompt(for option: RouteDescriptionInput) -> String {
|
private func buildPrompt(for option: RouteDescriptionInput) -> String {
|
||||||
let citiesText = option.cities.joined(separator: ", ")
|
let cities = option.cities.joined(separator: ", ")
|
||||||
let sportsText = option.sports.isEmpty ? "sports" : option.sports.joined(separator: ", ")
|
let sports = option.sports.joined(separator: "/")
|
||||||
|
let miles = option.totalMiles > 0 ? ", \(Int(option.totalMiles))mi" : ""
|
||||||
var details = [String]()
|
return "\(option.totalGames) \(sports) games, \(option.cities.count) cities: \(cities)\(miles)"
|
||||||
details.append("\(option.totalGames) games")
|
|
||||||
details.append("\(option.cities.count) cities: \(citiesText)")
|
|
||||||
|
|
||||||
if option.totalMiles > 0 {
|
|
||||||
details.append("\(Int(option.totalMiles)) miles")
|
|
||||||
}
|
|
||||||
if option.totalDrivingHours > 0 {
|
|
||||||
details.append("\(String(format: "%.1f", option.totalDrivingHours)) hours driving")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return """
|
|
||||||
Write a brief, exciting 1-sentence description for this sports road trip:
|
|
||||||
- Route: \(citiesText)
|
|
||||||
- Sports: \(sportsText)
|
|
||||||
- Details: \(details.joined(separator: ", "))
|
|
||||||
|
|
||||||
Make it sound like an adventure. Be concise.
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the cache (e.g., when starting a new trip search)
|
|
||||||
func clearCache() {
|
func clearCache() {
|
||||||
cache.removeAll()
|
aiCache.removeAll()
|
||||||
|
consecutiveFailures = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +278,6 @@ struct RouteDescriptionInput: Identifiable {
|
|||||||
self.id = option.id
|
self.id = option.id
|
||||||
self.cities = Array(NSOrderedSet(array: option.stops.map { $0.city })) as? [String] ?? []
|
self.cities = Array(NSOrderedSet(array: option.stops.map { $0.city })) as? [String] ?? []
|
||||||
|
|
||||||
// Extract sports from games
|
|
||||||
let gameIds = option.stops.flatMap { $0.games }
|
let gameIds = option.stops.flatMap { $0.games }
|
||||||
let sportSet = Set(gameIds.compactMap { games[$0]?.game.sport.rawValue })
|
let sportSet = Set(gameIds.compactMap { games[$0]?.game.sport.rawValue })
|
||||||
self.sports = Array(sportSet)
|
self.sports = Array(sportSet)
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
private var flatItems: [ItineraryRowItem] = []
|
private(set) var flatItems: [ItineraryRowItem] = []
|
||||||
var travelValidRanges: [String: ClosedRange<Int>] = [:] // travelId -> valid day range
|
var travelValidRanges: [String: ClosedRange<Int>] = [:] // travelId -> valid day range
|
||||||
var colorScheme: ColorScheme = .dark
|
var colorScheme: ColorScheme = .dark
|
||||||
|
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ struct TripOptionsView: View {
|
|||||||
TripDetailView(trip: trip)
|
TripDetailView(trip: trip)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
RouteDescriptionGenerator.shared.prewarm()
|
||||||
if isDemoMode && !hasAppliedDemoSelection {
|
if isDemoMode && !hasAppliedDemoSelection {
|
||||||
hasAppliedDemoSelection = true
|
hasAppliedDemoSelection = true
|
||||||
// Auto-select "Most Games" sort after a delay
|
// Auto-select "Most Games" sort after a delay
|
||||||
@@ -491,7 +492,11 @@ struct TripOptionCard: View {
|
|||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
@State private var aiDescription: String?
|
@State private var aiDescription: String?
|
||||||
@State private var isLoadingDescription = false
|
|
||||||
|
private var templateDescription: String {
|
||||||
|
let input = RouteDescriptionInput(from: option, games: games)
|
||||||
|
return RouteDescriptionGenerator.shared.templateDescription(for: input)
|
||||||
|
}
|
||||||
|
|
||||||
private var uniqueCities: [String] {
|
private var uniqueCities: [String] {
|
||||||
option.stops.map { $0.city }.removingDuplicates()
|
option.stops.map { $0.city }.removingDuplicates()
|
||||||
@@ -571,21 +576,6 @@ struct TripOptionCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-generated description (after stats)
|
|
||||||
if let description = aiDescription {
|
|
||||||
Text(description)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.transition(.opacity)
|
|
||||||
} else if isLoadingDescription {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
LoadingSpinner(size: .small)
|
|
||||||
Text("Generating...")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -606,28 +596,6 @@ struct TripOptionCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.task(id: option.id) {
|
|
||||||
// Reset state when option changes
|
|
||||||
aiDescription = nil
|
|
||||||
isLoadingDescription = false
|
|
||||||
await generateDescription()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func generateDescription() async {
|
|
||||||
guard RouteDescriptionGenerator.shared.isAvailable else { return }
|
|
||||||
|
|
||||||
isLoadingDescription = true
|
|
||||||
|
|
||||||
// Build input from THIS specific option
|
|
||||||
let input = RouteDescriptionInput(from: option, games: games)
|
|
||||||
|
|
||||||
if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) {
|
|
||||||
Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
|
|
||||||
aiDescription = description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isLoadingDescription = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ enum GameDAGRouter {
|
|||||||
constraints: DrivingConstraints,
|
constraints: DrivingConstraints,
|
||||||
anchorGameIds: Set<String> = [],
|
anchorGameIds: Set<String> = [],
|
||||||
allowRepeatCities: Bool = true,
|
allowRepeatCities: Bool = true,
|
||||||
beamWidth: Int = defaultBeamWidth
|
beamWidth: Int = defaultBeamWidth,
|
||||||
|
routePreference: RoutePreference = .balanced
|
||||||
) -> [[Game]] {
|
) -> [[Game]] {
|
||||||
|
|
||||||
// Edge cases
|
// Edge cases
|
||||||
@@ -254,7 +255,7 @@ enum GameDAGRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Final diversity selection
|
// Step 6: Final diversity selection
|
||||||
let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions)
|
let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions, routePreference: routePreference)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("🔍 DAG: Input games=\(games.count), beam=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)")
|
print("🔍 DAG: Input games=\(games.count), beam=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)")
|
||||||
@@ -269,6 +270,7 @@ enum GameDAGRouter {
|
|||||||
stadiums: [String: Stadium],
|
stadiums: [String: Stadium],
|
||||||
anchorGameIds: Set<String> = [],
|
anchorGameIds: Set<String> = [],
|
||||||
allowRepeatCities: Bool = true,
|
allowRepeatCities: Bool = true,
|
||||||
|
routePreference: RoutePreference = .balanced,
|
||||||
stopBuilder: ([Game], [String: Stadium]) -> [ItineraryStop]
|
stopBuilder: ([Game], [String: Stadium]) -> [ItineraryStop]
|
||||||
) -> [[Game]] {
|
) -> [[Game]] {
|
||||||
let constraints = DrivingConstraints.default
|
let constraints = DrivingConstraints.default
|
||||||
@@ -277,7 +279,8 @@ enum GameDAGRouter {
|
|||||||
stadiums: stadiums,
|
stadiums: stadiums,
|
||||||
constraints: constraints,
|
constraints: constraints,
|
||||||
anchorGameIds: anchorGameIds,
|
anchorGameIds: anchorGameIds,
|
||||||
allowRepeatCities: allowRepeatCities
|
allowRepeatCities: allowRepeatCities,
|
||||||
|
routePreference: routePreference
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +295,8 @@ enum GameDAGRouter {
|
|||||||
private static func selectDiverseRoutes(
|
private static func selectDiverseRoutes(
|
||||||
_ routes: [[Game]],
|
_ routes: [[Game]],
|
||||||
stadiums: [String: Stadium],
|
stadiums: [String: Stadium],
|
||||||
maxCount: Int
|
maxCount: Int,
|
||||||
|
routePreference: RoutePreference = .balanced
|
||||||
) -> [[Game]] {
|
) -> [[Game]] {
|
||||||
guard !routes.isEmpty else { return [] }
|
guard !routes.isEmpty else { return [] }
|
||||||
|
|
||||||
@@ -319,8 +323,9 @@ enum GameDAGRouter {
|
|||||||
let byGames = Dictionary(grouping: uniqueProfiles) { $0.gameBucket }
|
let byGames = Dictionary(grouping: uniqueProfiles) { $0.gameBucket }
|
||||||
for bucket in byGames.keys.sorted() {
|
for bucket in byGames.keys.sorted() {
|
||||||
if selected.count >= maxCount { break }
|
if selected.count >= maxCount { break }
|
||||||
if let candidates = byGames[bucket]?.sorted(by: { $0.totalMiles < $1.totalMiles }) {
|
if let candidates = byGames[bucket] {
|
||||||
if let best = candidates.first, !selectedKeys.contains(best.uniqueKey) {
|
let sorted = sortByPreference(candidates, routePreference: routePreference)
|
||||||
|
if let best = sorted.first(where: { !selectedKeys.contains($0.uniqueKey) }) {
|
||||||
selected.append(best)
|
selected.append(best)
|
||||||
selectedKeys.insert(best.uniqueKey)
|
selectedKeys.insert(best.uniqueKey)
|
||||||
}
|
}
|
||||||
@@ -331,8 +336,10 @@ enum GameDAGRouter {
|
|||||||
let byCities = Dictionary(grouping: uniqueProfiles) { $0.cityBucket }
|
let byCities = Dictionary(grouping: uniqueProfiles) { $0.cityBucket }
|
||||||
for bucket in byCities.keys.sorted() {
|
for bucket in byCities.keys.sorted() {
|
||||||
if selected.count >= maxCount { break }
|
if selected.count >= maxCount { break }
|
||||||
if let candidates = byCities[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
|
let candidates = (byCities[bucket] ?? []).filter { !selectedKeys.contains($0.uniqueKey) }
|
||||||
if let best = candidates.sorted(by: { $0.totalMiles < $1.totalMiles }).first {
|
if !candidates.isEmpty {
|
||||||
|
let sorted = sortByPreference(candidates, routePreference: routePreference)
|
||||||
|
if let best = sorted.first {
|
||||||
selected.append(best)
|
selected.append(best)
|
||||||
selectedKeys.insert(best.uniqueKey)
|
selectedKeys.insert(best.uniqueKey)
|
||||||
}
|
}
|
||||||
@@ -340,8 +347,20 @@ enum GameDAGRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pass 3: Ensure at least one route per mileage bucket
|
// Pass 3: Ensure at least one route per mileage bucket
|
||||||
|
// Bias bucket iteration order based on route preference
|
||||||
let byMiles = Dictionary(grouping: uniqueProfiles) { $0.milesBucket }
|
let byMiles = Dictionary(grouping: uniqueProfiles) { $0.milesBucket }
|
||||||
for bucket in byMiles.keys.sorted() {
|
let milesBucketOrder: [Int]
|
||||||
|
switch routePreference {
|
||||||
|
case .direct:
|
||||||
|
// Prioritize low mileage buckets first
|
||||||
|
milesBucketOrder = byMiles.keys.sorted()
|
||||||
|
case .scenic:
|
||||||
|
// Prioritize high mileage buckets first (more cities = more scenic)
|
||||||
|
milesBucketOrder = byMiles.keys.sorted(by: >)
|
||||||
|
case .balanced:
|
||||||
|
milesBucketOrder = byMiles.keys.sorted()
|
||||||
|
}
|
||||||
|
for bucket in milesBucketOrder {
|
||||||
if selected.count >= maxCount { break }
|
if selected.count >= maxCount { break }
|
||||||
if let candidates = byMiles[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
|
if let candidates = byMiles[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
|
||||||
if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
|
if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
|
||||||
@@ -355,8 +374,10 @@ enum GameDAGRouter {
|
|||||||
let byDays = Dictionary(grouping: uniqueProfiles) { $0.daysBucket }
|
let byDays = Dictionary(grouping: uniqueProfiles) { $0.daysBucket }
|
||||||
for bucket in byDays.keys.sorted() {
|
for bucket in byDays.keys.sorted() {
|
||||||
if selected.count >= maxCount { break }
|
if selected.count >= maxCount { break }
|
||||||
if let candidates = byDays[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
|
let candidates = (byDays[bucket] ?? []).filter { !selectedKeys.contains($0.uniqueKey) }
|
||||||
if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
|
if !candidates.isEmpty {
|
||||||
|
let sorted = sortByPreference(candidates, routePreference: routePreference)
|
||||||
|
if let best = sorted.first {
|
||||||
selected.append(best)
|
selected.append(best)
|
||||||
selectedKeys.insert(best.uniqueKey)
|
selectedKeys.insert(best.uniqueKey)
|
||||||
}
|
}
|
||||||
@@ -391,11 +412,24 @@ enum GameDAGRouter {
|
|||||||
if !addedAny { break }
|
if !addedAny { break }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 6: If still need more, add remaining sorted by efficiency
|
// Pass 6: If still need more, add remaining sorted by route preference
|
||||||
if selected.count < maxCount {
|
if selected.count < maxCount {
|
||||||
let stillRemaining = uniqueProfiles
|
let stillRemaining = uniqueProfiles
|
||||||
.filter { !selectedKeys.contains($0.uniqueKey) }
|
.filter { !selectedKeys.contains($0.uniqueKey) }
|
||||||
.sorted { efficiency(for: $0) > efficiency(for: $1) }
|
.sorted { a, b in
|
||||||
|
switch routePreference {
|
||||||
|
case .direct:
|
||||||
|
// Prefer lowest mileage routes
|
||||||
|
return a.totalMiles < b.totalMiles
|
||||||
|
case .scenic:
|
||||||
|
// Prefer routes with more unique cities
|
||||||
|
if a.cityCount != b.cityCount { return a.cityCount > b.cityCount }
|
||||||
|
return a.totalMiles > b.totalMiles
|
||||||
|
case .balanced:
|
||||||
|
// Use efficiency (games per driving hour)
|
||||||
|
return efficiency(for: a) > efficiency(for: b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for profile in stillRemaining.prefix(maxCount - selected.count) {
|
for profile in stillRemaining.prefix(maxCount - selected.count) {
|
||||||
selected.append(profile)
|
selected.append(profile)
|
||||||
@@ -509,6 +543,27 @@ enum GameDAGRouter {
|
|||||||
return Double(profile.gameCount) / drivingHours
|
return Double(profile.gameCount) / drivingHours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sorts route profiles within a bucket based on route preference.
|
||||||
|
/// - Direct: lowest mileage first
|
||||||
|
/// - Scenic: most cities first, then highest mileage
|
||||||
|
/// - Balanced: best efficiency (games per driving hour)
|
||||||
|
private static func sortByPreference(
|
||||||
|
_ profiles: [RouteProfile],
|
||||||
|
routePreference: RoutePreference
|
||||||
|
) -> [RouteProfile] {
|
||||||
|
profiles.sorted { a, b in
|
||||||
|
switch routePreference {
|
||||||
|
case .direct:
|
||||||
|
return a.totalMiles < b.totalMiles
|
||||||
|
case .scenic:
|
||||||
|
if a.cityCount != b.cityCount { return a.cityCount > b.cityCount }
|
||||||
|
return a.totalMiles > b.totalMiles
|
||||||
|
case .balanced:
|
||||||
|
return efficiency(for: a) > efficiency(for: b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Day Bucketing
|
// MARK: - Day Bucketing
|
||||||
|
|
||||||
private static func bucketByDay(games: [Game]) -> [Int: [Game]] {
|
private static func bucketByDay(games: [Game]) -> [Int: [Game]] {
|
||||||
@@ -541,8 +596,14 @@ enum GameDAGRouter {
|
|||||||
// Time must move forward
|
// Time must move forward
|
||||||
guard to.startTime > from.startTime else { return false }
|
guard to.startTime > from.startTime else { return false }
|
||||||
|
|
||||||
// Same stadium = always feasible
|
// Same stadium: check for sufficient time gap between games
|
||||||
if from.stadiumId == to.stadiumId { return true }
|
if from.stadiumId == to.stadiumId {
|
||||||
|
let estimatedGameDurationHours: Double = 3.0
|
||||||
|
let departureTime = from.startTime.addingTimeInterval(estimatedGameDurationHours * 3600)
|
||||||
|
let hoursAvailable = to.startTime.timeIntervalSince(departureTime) / 3600.0
|
||||||
|
let minGapHours: Double = 1.0
|
||||||
|
return hoursAvailable >= minGapHours
|
||||||
|
}
|
||||||
|
|
||||||
// Get stadiums
|
// Get stadiums
|
||||||
guard let fromStadium = stadiums[from.stadiumId],
|
guard let fromStadium = stadiums[from.stadiumId],
|
||||||
@@ -593,9 +654,12 @@ enum GameDAGRouter {
|
|||||||
// Calculate driving hours available
|
// Calculate driving hours available
|
||||||
// For same-day games: enforce both time availability AND daily driving limit
|
// For same-day games: enforce both time availability AND daily driving limit
|
||||||
// For multi-day trips: use total available driving hours across days
|
// For multi-day trips: use total available driving hours across days
|
||||||
|
// daysBetween counts calendar day gaps, but the traveler can drive on
|
||||||
|
// partial days at both ends (post-game evening + pre-game morning), so
|
||||||
|
// use (daysBetween + 1) for multi-day trips to avoid off-by-one rejection.
|
||||||
let maxDrivingHoursAvailable = daysBetween == 0
|
let maxDrivingHoursAvailable = daysBetween == 0
|
||||||
? min(max(0, availableHours), constraints.maxDailyDrivingHours)
|
? min(max(0, availableHours), constraints.maxDailyDrivingHours)
|
||||||
: Double(daysBetween) * constraints.maxDailyDrivingHours
|
: Double(daysBetween + 1) * constraints.maxDailyDrivingHours
|
||||||
|
|
||||||
let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
|
let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
|
||||||
|
|
||||||
@@ -621,7 +685,7 @@ enum GameDAGRouter {
|
|||||||
|
|
||||||
guard let fromStadium = stadiums[from.stadiumId],
|
guard let fromStadium = stadiums[from.stadiumId],
|
||||||
let toStadium = stadiums[to.stadiumId] else {
|
let toStadium = stadiums[to.stadiumId] else {
|
||||||
return 300 // Fallback estimate
|
return 0 // Missing stadium data — cannot estimate distance
|
||||||
}
|
}
|
||||||
|
|
||||||
return TravelEstimator.haversineDistanceMiles(
|
return TravelEstimator.haversineDistanceMiles(
|
||||||
|
|||||||
@@ -81,14 +81,21 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
// Get all games that fall within the user's travel dates.
|
// Get all games that fall within the user's travel dates.
|
||||||
// Sort by start time so we visit them in chronological order.
|
// Sort by start time so we visit them in chronological order.
|
||||||
let selectedRegions = request.preferences.selectedRegions
|
let selectedRegions = request.preferences.selectedRegions
|
||||||
|
var gamesWithMissingStadium = 0
|
||||||
let gamesInRange = request.allGames
|
let gamesInRange = request.allGames
|
||||||
.filter { game in
|
.filter { game in
|
||||||
// Must be in date range
|
// Must be in date range
|
||||||
guard dateRange.contains(game.startTime) else { return false }
|
guard dateRange.contains(game.startTime) else { return false }
|
||||||
|
|
||||||
|
// Track games with missing stadium data
|
||||||
|
guard request.stadiums[game.stadiumId] != nil else {
|
||||||
|
gamesWithMissingStadium += 1
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Must be in selected region (if regions specified)
|
// Must be in selected region (if regions specified)
|
||||||
if !selectedRegions.isEmpty {
|
if !selectedRegions.isEmpty {
|
||||||
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
let stadium = request.stadiums[game.stadiumId]!
|
||||||
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
||||||
return selectedRegions.contains(gameRegion)
|
return selectedRegions.contains(gameRegion)
|
||||||
}
|
}
|
||||||
@@ -98,10 +105,18 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
|
|
||||||
// No games? Nothing to plan.
|
// No games? Nothing to plan.
|
||||||
if gamesInRange.isEmpty {
|
if gamesInRange.isEmpty {
|
||||||
|
var violations: [ConstraintViolation] = []
|
||||||
|
if gamesWithMissingStadium > 0 {
|
||||||
|
violations.append(ConstraintViolation(
|
||||||
|
type: .missingData,
|
||||||
|
description: "\(gamesWithMissingStadium) game(s) excluded due to missing stadium data",
|
||||||
|
severity: .warning
|
||||||
|
))
|
||||||
|
}
|
||||||
return .failure(
|
return .failure(
|
||||||
PlanningFailure(
|
PlanningFailure(
|
||||||
reason: .noGamesInRange,
|
reason: .noGamesInRange,
|
||||||
violations: []
|
violations: violations
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -165,6 +180,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
from: filteredGames,
|
from: filteredGames,
|
||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
validRoutes.append(contentsOf: globalRoutes)
|
validRoutes.append(contentsOf: globalRoutes)
|
||||||
@@ -173,7 +189,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
let regionalRoutes = findRoutesPerRegion(
|
let regionalRoutes = findRoutesPerRegion(
|
||||||
games: filteredGames,
|
games: filteredGames,
|
||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference
|
||||||
)
|
)
|
||||||
validRoutes.append(contentsOf: regionalRoutes)
|
validRoutes.append(contentsOf: regionalRoutes)
|
||||||
|
|
||||||
@@ -478,7 +495,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
private func findRoutesPerRegion(
|
private func findRoutesPerRegion(
|
||||||
games: [Game],
|
games: [Game],
|
||||||
stadiums: [String: Stadium],
|
stadiums: [String: Stadium],
|
||||||
allowRepeatCities: Bool
|
allowRepeatCities: Bool,
|
||||||
|
routePreference: RoutePreference = .balanced
|
||||||
) -> [[Game]] {
|
) -> [[Game]] {
|
||||||
// Partition games by region
|
// Partition games by region
|
||||||
var gamesByRegion: [Region: [Game]] = [:]
|
var gamesByRegion: [Region: [Game]] = [:]
|
||||||
@@ -510,6 +528,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
from: regionGames,
|
from: regionGames,
|
||||||
stadiums: stadiums,
|
stadiums: stadiums,
|
||||||
allowRepeatCities: allowRepeatCities,
|
allowRepeatCities: allowRepeatCities,
|
||||||
|
routePreference: routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
anchorGameIds: anchorGameIds,
|
anchorGameIds: anchorGameIds,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
validRoutes.append(contentsOf: globalRoutes)
|
validRoutes.append(contentsOf: globalRoutes)
|
||||||
@@ -172,7 +173,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
games: gamesInRange,
|
games: gamesInRange,
|
||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
anchorGameIds: anchorGameIds,
|
anchorGameIds: anchorGameIds,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference
|
||||||
)
|
)
|
||||||
validRoutes.append(contentsOf: regionalRoutes)
|
validRoutes.append(contentsOf: regionalRoutes)
|
||||||
|
|
||||||
@@ -437,7 +439,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
games: [Game],
|
games: [Game],
|
||||||
stadiums: [String: Stadium],
|
stadiums: [String: Stadium],
|
||||||
anchorGameIds: Set<String>,
|
anchorGameIds: Set<String>,
|
||||||
allowRepeatCities: Bool
|
allowRepeatCities: Bool,
|
||||||
|
routePreference: RoutePreference = .balanced
|
||||||
) -> [[Game]] {
|
) -> [[Game]] {
|
||||||
// First, determine which region(s) the anchor games are in
|
// First, determine which region(s) the anchor games are in
|
||||||
var anchorRegions = Set<Region>()
|
var anchorRegions = Set<Region>()
|
||||||
@@ -482,6 +485,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
stadiums: stadiums,
|
stadiums: stadiums,
|
||||||
anchorGameIds: regionAnchorIds,
|
anchorGameIds: regionAnchorIds,
|
||||||
allowRepeatCities: allowRepeatCities,
|
allowRepeatCities: allowRepeatCities,
|
||||||
|
routePreference: routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
|||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
anchorGameIds: [], // No anchors in Scenario C
|
anchorGameIds: [], // No anchors in Scenario C
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
|||||||
from: finalGames,
|
from: finalGames,
|
||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
/// Maximum number of results to return
|
/// Maximum number of results to return
|
||||||
private let maxResultsToReturn = 10
|
private let maxResultsToReturn = 10
|
||||||
|
|
||||||
|
/// Current date used to filter out past windows. Injectable for testing.
|
||||||
|
private let currentDate: Date
|
||||||
|
|
||||||
|
init(currentDate: Date = Date()) {
|
||||||
|
self.currentDate = currentDate
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ScenarioPlanner Protocol
|
// MARK: - ScenarioPlanner Protocol
|
||||||
|
|
||||||
func plan(request: PlanningRequest) -> ItineraryResult {
|
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||||
@@ -90,9 +97,16 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
// the user wants to visit each team's home stadium.
|
// the user wants to visit each team's home stadium.
|
||||||
var homeGamesByTeam: [String: [Game]] = [:]
|
var homeGamesByTeam: [String: [Game]] = [:]
|
||||||
var allHomeGames: [Game] = []
|
var allHomeGames: [Game] = []
|
||||||
|
let selectedRegions = request.preferences.selectedRegions
|
||||||
|
|
||||||
for game in request.allGames {
|
for game in request.allGames {
|
||||||
if selectedTeamIds.contains(game.homeTeamId) {
|
if selectedTeamIds.contains(game.homeTeamId) {
|
||||||
|
// Apply region filter if regions are specified
|
||||||
|
if !selectedRegions.isEmpty {
|
||||||
|
guard let stadium = request.stadiums[game.stadiumId] else { continue }
|
||||||
|
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
||||||
|
guard selectedRegions.contains(gameRegion) else { continue }
|
||||||
|
}
|
||||||
homeGamesByTeam[game.homeTeamId, default: []].append(game)
|
homeGamesByTeam[game.homeTeamId, default: []].append(game)
|
||||||
allHomeGames.append(game)
|
allHomeGames.append(game)
|
||||||
}
|
}
|
||||||
@@ -174,7 +188,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
var allItineraryOptions: [ItineraryOption] = []
|
var allItineraryOptions: [ItineraryOption] = []
|
||||||
|
|
||||||
for (windowIndex, window) in windowsToEvaluate.enumerated() {
|
for window in windowsToEvaluate {
|
||||||
// Collect games in this window
|
// Collect games in this window
|
||||||
var gamesByTeamInWindow: [String: [Game]] = [:]
|
var gamesByTeamInWindow: [String: [Game]] = [:]
|
||||||
var hasAllTeamsInWindow = true
|
var hasAllTeamsInWindow = true
|
||||||
@@ -212,6 +226,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
anchorGameIds: earliestAnchorIds,
|
anchorGameIds: earliestAnchorIds,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
var validRoutes = candidateRoutes.filter { route in
|
var validRoutes = candidateRoutes.filter { route in
|
||||||
@@ -230,6 +245,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
anchorGameIds: latestAnchorIds,
|
anchorGameIds: latestAnchorIds,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
candidateRoutes.append(contentsOf: latestAnchorRoutes)
|
candidateRoutes.append(contentsOf: latestAnchorRoutes)
|
||||||
@@ -239,6 +255,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
from: uniqueGames,
|
from: uniqueGames,
|
||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
|
routePreference: request.preferences.routePreference,
|
||||||
stopBuilder: buildStops
|
stopBuilder: buildStops
|
||||||
)
|
)
|
||||||
candidateRoutes.append(contentsOf: noAnchorRoutes)
|
candidateRoutes.append(contentsOf: noAnchorRoutes)
|
||||||
@@ -286,13 +303,10 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
allItineraryOptions.append(option)
|
allItineraryOptions.append(option)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Early exit if we have enough options
|
// No early exit — evaluate all sampled windows so results
|
||||||
if allItineraryOptions.count >= maxResultsToReturn * 5 {
|
// span the full season instead of clustering around early dates.
|
||||||
#if DEBUG
|
// The 50-window sample cap + final dedup + top-10 ranking are
|
||||||
print("🔍 ScenarioE: Early exit at window \(windowIndex + 1) with \(allItineraryOptions.count) options")
|
// sufficient throttles (beam search on ~10 games/window is fast).
|
||||||
#endif
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
@@ -374,9 +388,12 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
let earliestDay = calendar.startOfDay(for: earliest)
|
let earliestDay = calendar.startOfDay(for: earliest)
|
||||||
let latestDay = calendar.startOfDay(for: latest)
|
let latestDay = calendar.startOfDay(for: latest)
|
||||||
|
|
||||||
|
// Skip past windows — users only want future trips
|
||||||
|
let today = calendar.startOfDay(for: currentDate)
|
||||||
|
|
||||||
// Generate sliding windows
|
// Generate sliding windows
|
||||||
var validWindows: [DateInterval] = []
|
var validWindows: [DateInterval] = []
|
||||||
var currentStart = earliestDay
|
var currentStart = max(earliestDay, today)
|
||||||
|
|
||||||
while let windowEnd = calendar.date(byAdding: .day, value: windowDurationDays, to: currentStart),
|
while let windowEnd = calendar.date(byAdding: .day, value: windowDurationDays, to: currentStart),
|
||||||
windowEnd <= calendar.date(byAdding: .day, value: 1, to: latestDay)! {
|
windowEnd <= calendar.date(byAdding: .day, value: 1, to: latestDay)! {
|
||||||
|
|||||||
@@ -17,20 +17,19 @@ import CoreLocation
|
|||||||
/// - Constants:
|
/// - Constants:
|
||||||
/// - averageSpeedMph: 60 mph
|
/// - averageSpeedMph: 60 mph
|
||||||
/// - roadRoutingFactor: 1.3 (accounts for roads vs straight-line distance)
|
/// - roadRoutingFactor: 1.3 (accounts for roads vs straight-line distance)
|
||||||
/// - fallbackDistanceMiles: 300 miles (when coordinates unavailable)
|
|
||||||
///
|
///
|
||||||
/// - Invariants:
|
/// - Invariants:
|
||||||
/// - All distance calculations are symmetric: distance(A,B) == distance(B,A)
|
/// - All distance calculations are symmetric: distance(A,B) == distance(B,A)
|
||||||
/// - Road distance >= straight-line distance (roadRoutingFactor >= 1.0)
|
/// - Road distance >= straight-line distance (roadRoutingFactor >= 1.0)
|
||||||
/// - Travel duration is always distance / averageSpeedMph
|
/// - Travel duration is always distance / averageSpeedMph
|
||||||
/// - Segments exceeding 5x maxDailyDrivingHours return nil (unreachable)
|
/// - Segments exceeding 5x maxDailyDrivingHours return nil (unreachable)
|
||||||
|
/// - Missing coordinates → returns nil (no guessing with fallback distances)
|
||||||
enum TravelEstimator {
|
enum TravelEstimator {
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
private static let averageSpeedMph: Double = 60.0
|
private static let averageSpeedMph: Double = 60.0
|
||||||
private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance
|
private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance
|
||||||
private static let fallbackDistanceMiles: Double = 300.0
|
|
||||||
|
|
||||||
// MARK: - Travel Estimation
|
// MARK: - Travel Estimation
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ enum TravelEstimator {
|
|||||||
///
|
///
|
||||||
/// - Expected Behavior:
|
/// - Expected Behavior:
|
||||||
/// - With valid coordinates → calculates distance using Haversine * roadRoutingFactor
|
/// - With valid coordinates → calculates distance using Haversine * roadRoutingFactor
|
||||||
/// - Missing coordinates → uses fallback distance (300 miles)
|
/// - Missing coordinates → returns nil (no fallback guessing)
|
||||||
/// - Same city (no coords) → 0 distance, 0 duration
|
/// - Same city (no coords) → 0 distance, 0 duration
|
||||||
/// - Driving hours > 5x maxDailyDrivingHours → returns nil
|
/// - Driving hours > 5x maxDailyDrivingHours → returns nil
|
||||||
/// - Duration = distance / 60 mph
|
/// - Duration = distance / 60 mph
|
||||||
@@ -55,7 +54,21 @@ enum TravelEstimator {
|
|||||||
constraints: DrivingConstraints
|
constraints: DrivingConstraints
|
||||||
) -> TravelSegment? {
|
) -> TravelSegment? {
|
||||||
|
|
||||||
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
// If either stop is missing coordinates, the segment is infeasible
|
||||||
|
// (unless same city, which returns 0 distance)
|
||||||
|
guard let distanceMiles = calculateDistanceMiles(from: from, to: to) else {
|
||||||
|
// Same city with no coords: zero-distance segment
|
||||||
|
if from.city == to.city {
|
||||||
|
return TravelSegment(
|
||||||
|
fromLocation: from.location,
|
||||||
|
toLocation: to.location,
|
||||||
|
travelMode: .drive,
|
||||||
|
distanceMeters: 0,
|
||||||
|
durationSeconds: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
let drivingHours = distanceMiles / averageSpeedMph
|
let drivingHours = distanceMiles / averageSpeedMph
|
||||||
|
|
||||||
// Maximum allowed: 5 days of driving as a conservative hard cap.
|
// Maximum allowed: 5 days of driving as a conservative hard cap.
|
||||||
@@ -126,22 +139,20 @@ enum TravelEstimator {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - from: Origin stop
|
/// - from: Origin stop
|
||||||
/// - to: Destination stop
|
/// - to: Destination stop
|
||||||
/// - Returns: Distance in miles
|
/// - Returns: Distance in miles, or nil if coordinates are missing
|
||||||
///
|
///
|
||||||
/// - Expected Behavior:
|
/// - Expected Behavior:
|
||||||
/// - Both have coordinates → Haversine distance * 1.3
|
/// - Both have coordinates → Haversine distance * 1.3
|
||||||
/// - Either missing coordinates → fallback distance
|
/// - Either missing coordinates → nil (no fallback guessing)
|
||||||
/// - Same city (no coords) → 0 miles
|
|
||||||
/// - Different cities (no coords) → 300 miles
|
|
||||||
static func calculateDistanceMiles(
|
static func calculateDistanceMiles(
|
||||||
from: ItineraryStop,
|
from: ItineraryStop,
|
||||||
to: ItineraryStop
|
to: ItineraryStop
|
||||||
) -> Double {
|
) -> Double? {
|
||||||
if let fromCoord = from.coordinate,
|
guard let fromCoord = from.coordinate,
|
||||||
let toCoord = to.coordinate {
|
let toCoord = to.coordinate else {
|
||||||
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
|
return nil
|
||||||
}
|
}
|
||||||
return estimateFallbackDistance(from: from, to: to)
|
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates straight-line distance in miles using Haversine formula.
|
/// Calculates straight-line distance in miles using Haversine formula.
|
||||||
@@ -206,24 +217,19 @@ enum TravelEstimator {
|
|||||||
return earthRadiusMeters * c
|
return earthRadiusMeters * c
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fallback distance when coordinates aren't available.
|
// MARK: - Overnight Stop Detection
|
||||||
|
|
||||||
|
/// Determines if a travel segment requires an overnight stop.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - from: Origin stop
|
/// - segment: The travel segment to evaluate
|
||||||
/// - to: Destination stop
|
/// - constraints: Driving constraints (max daily hours)
|
||||||
/// - Returns: Estimated distance in miles
|
/// - Returns: true if driving hours exceed the daily limit
|
||||||
///
|
static func requiresOvernightStop(
|
||||||
/// - Expected Behavior:
|
segment: TravelSegment,
|
||||||
/// - Same city → 0 miles
|
constraints: DrivingConstraints
|
||||||
/// - Different cities → 300 miles (fallback constant)
|
) -> Bool {
|
||||||
static func estimateFallbackDistance(
|
segment.estimatedDrivingHours > constraints.maxDailyDrivingHours
|
||||||
from: ItineraryStop,
|
|
||||||
to: ItineraryStop
|
|
||||||
) -> Double {
|
|
||||||
if from.city == to.city {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return fallbackDistanceMiles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Travel Days
|
// MARK: - Travel Days
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import Foundation
|
|||||||
///
|
///
|
||||||
final class TripPlanningEngine {
|
final class TripPlanningEngine {
|
||||||
|
|
||||||
|
/// Warnings generated during the last planning run.
|
||||||
|
/// Populated when options are filtered out but valid results remain.
|
||||||
|
private(set) var warnings: [ConstraintViolation] = []
|
||||||
|
|
||||||
/// Plans itineraries based on the request inputs.
|
/// Plans itineraries based on the request inputs.
|
||||||
/// Automatically detects which scenario applies and delegates to the appropriate planner.
|
/// Automatically detects which scenario applies and delegates to the appropriate planner.
|
||||||
///
|
///
|
||||||
@@ -31,6 +35,32 @@ final class TripPlanningEngine {
|
|||||||
/// - Returns: Ranked itineraries on success, or explicit failure with reason
|
/// - Returns: Ranked itineraries on success, or explicit failure with reason
|
||||||
func planItineraries(request: PlanningRequest) -> ItineraryResult {
|
func planItineraries(request: PlanningRequest) -> ItineraryResult {
|
||||||
|
|
||||||
|
// Reset warnings from previous run
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
// Warn on empty sports set
|
||||||
|
if request.preferences.sports.isEmpty {
|
||||||
|
warnings.append(ConstraintViolation(
|
||||||
|
type: .missingData,
|
||||||
|
description: "No sports selected — results may be empty",
|
||||||
|
severity: .warning
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date range is not inverted
|
||||||
|
if request.preferences.endDate < request.preferences.startDate {
|
||||||
|
return .failure(PlanningFailure(
|
||||||
|
reason: .missingDateRange,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .dateRange,
|
||||||
|
description: "End date is before start date",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// Detect scenario and get the appropriate planner
|
// Detect scenario and get the appropriate planner
|
||||||
let planner = ScenarioPlannerFactory.planner(for: request)
|
let planner = ScenarioPlannerFactory.planner(for: request)
|
||||||
|
|
||||||
@@ -45,6 +75,7 @@ final class TripPlanningEngine {
|
|||||||
|
|
||||||
/// Applies allowRepeatCities filter after scenario planners return.
|
/// Applies allowRepeatCities filter after scenario planners return.
|
||||||
/// Note: Region filtering is done during game selection in scenario planners.
|
/// Note: Region filtering is done during game selection in scenario planners.
|
||||||
|
/// Tracks excluded options as warnings when valid results remain.
|
||||||
private func applyPreferenceFilters(
|
private func applyPreferenceFilters(
|
||||||
to result: ItineraryResult,
|
to result: ItineraryResult,
|
||||||
request: PlanningRequest
|
request: PlanningRequest
|
||||||
@@ -56,6 +87,7 @@ final class TripPlanningEngine {
|
|||||||
var options = originalOptions
|
var options = originalOptions
|
||||||
|
|
||||||
// Filter repeat cities (this is enforced during beam search, but double-check here)
|
// Filter repeat cities (this is enforced during beam search, but double-check here)
|
||||||
|
let preRepeatCount = options.count
|
||||||
options = RouteFilters.filterRepeatCities(
|
options = RouteFilters.filterRepeatCities(
|
||||||
options,
|
options,
|
||||||
allow: request.preferences.allowRepeatCities
|
allow: request.preferences.allowRepeatCities
|
||||||
@@ -68,7 +100,77 @@ final class TripPlanningEngine {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Region filtering is applied during game selection in scenario planners
|
let repeatCityExcluded = preRepeatCount - options.count
|
||||||
|
if repeatCityExcluded > 0 {
|
||||||
|
warnings.append(ConstraintViolation(
|
||||||
|
type: .general,
|
||||||
|
description: "\(repeatCityExcluded) route(s) excluded for visiting the same city on multiple days",
|
||||||
|
severity: .warning
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must-stop filter: ensure all must-stop cities appear in routes
|
||||||
|
if !request.preferences.mustStopLocations.isEmpty {
|
||||||
|
let requiredCities = request.preferences.mustStopLocations
|
||||||
|
.map { $0.name.lowercased() }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
if !requiredCities.isEmpty {
|
||||||
|
let preMustStopCount = options.count
|
||||||
|
options = options.filter { option in
|
||||||
|
let tripCities = Set(option.stops.map { $0.city.lowercased() })
|
||||||
|
return requiredCities.allSatisfy { tripCities.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.isEmpty {
|
||||||
|
return .failure(PlanningFailure(
|
||||||
|
reason: .noValidRoutes,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .mustStop,
|
||||||
|
description: "No routes include all must-stop cities",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
let mustStopExcluded = preMustStopCount - options.count
|
||||||
|
if mustStopExcluded > 0 {
|
||||||
|
let cityList = requiredCities.joined(separator: ", ")
|
||||||
|
warnings.append(ConstraintViolation(
|
||||||
|
type: .mustStop,
|
||||||
|
description: "\(mustStopExcluded) route(s) excluded for missing must-stop cities: \(cityList)",
|
||||||
|
severity: .warning
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate travel segments: filter out invalid options
|
||||||
|
let preValidCount = options.count
|
||||||
|
options = options.filter { $0.isValid }
|
||||||
|
if options.isEmpty {
|
||||||
|
return .failure(PlanningFailure(
|
||||||
|
reason: .noValidRoutes,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .segmentMismatch,
|
||||||
|
description: "No valid itineraries could be built",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
let segmentExcluded = preValidCount - options.count
|
||||||
|
if segmentExcluded > 0 {
|
||||||
|
warnings.append(ConstraintViolation(
|
||||||
|
type: .segmentMismatch,
|
||||||
|
description: "\(segmentExcluded) route(s) excluded due to invalid travel segments",
|
||||||
|
severity: .warning
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
return .success(options)
|
return .success(options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ enum ConstraintType: String, Equatable {
|
|||||||
case selectedGames
|
case selectedGames
|
||||||
case gameReachability
|
case gameReachability
|
||||||
case general
|
case general
|
||||||
|
case segmentMismatch
|
||||||
|
case missingData
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ViolationSeverity: Equatable {
|
enum ViolationSeverity: Equatable {
|
||||||
@@ -196,6 +198,70 @@ struct ItineraryOption: Identifiable {
|
|||||||
stops.reduce(0) { $0 + $1.games.count }
|
stops.reduce(0) { $0 + $1.games.count }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-sorts and ranks itinerary options based on route preference.
|
||||||
|
///
|
||||||
|
/// Used to re-order results post-planning when the user toggles route preference
|
||||||
|
/// without re-running the full planner.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - options: The itinerary options to sort
|
||||||
|
/// - routePreference: The user's route preference
|
||||||
|
/// - Returns: Sorted and ranked options (all options, no limit)
|
||||||
|
///
|
||||||
|
/// - Expected Behavior:
|
||||||
|
/// - Empty options → empty result
|
||||||
|
/// - All options are returned (no filtering)
|
||||||
|
/// - Ranks are reassigned 1, 2, 3... after sorting
|
||||||
|
///
|
||||||
|
/// Sorting behavior by route preference:
|
||||||
|
/// - Direct: Lowest mileage first (minimize driving)
|
||||||
|
/// - Scenic: Most cities first, then highest mileage (maximize exploration)
|
||||||
|
/// - Balanced: Best efficiency (games per driving hour)
|
||||||
|
///
|
||||||
|
/// - Invariants:
|
||||||
|
/// - Output count == input count
|
||||||
|
/// - Ranks are sequential starting at 1
|
||||||
|
static func sortByRoutePreference(
|
||||||
|
_ options: [ItineraryOption],
|
||||||
|
routePreference: RoutePreference
|
||||||
|
) -> [ItineraryOption] {
|
||||||
|
let sorted = options.sorted { a, b in
|
||||||
|
switch routePreference {
|
||||||
|
case .direct:
|
||||||
|
// Lowest mileage first
|
||||||
|
if a.totalDistanceMiles != b.totalDistanceMiles {
|
||||||
|
return a.totalDistanceMiles < b.totalDistanceMiles
|
||||||
|
}
|
||||||
|
return a.totalDrivingHours < b.totalDrivingHours
|
||||||
|
|
||||||
|
case .scenic:
|
||||||
|
// Most unique cities first, then highest mileage
|
||||||
|
let aCities = Set(a.stops.map { $0.city }).count
|
||||||
|
let bCities = Set(b.stops.map { $0.city }).count
|
||||||
|
if aCities != bCities { return aCities > bCities }
|
||||||
|
return a.totalDistanceMiles > b.totalDistanceMiles
|
||||||
|
|
||||||
|
case .balanced:
|
||||||
|
// Best efficiency (games per driving hour)
|
||||||
|
let effA = a.totalDrivingHours > 0 ? Double(a.totalGames) / a.totalDrivingHours : Double(a.totalGames)
|
||||||
|
let effB = b.totalDrivingHours > 0 ? Double(b.totalGames) / b.totalDrivingHours : Double(b.totalGames)
|
||||||
|
if effA != effB { return effA > effB }
|
||||||
|
return a.totalGames > b.totalGames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted.enumerated().map { index, option in
|
||||||
|
ItineraryOption(
|
||||||
|
rank: index + 1,
|
||||||
|
stops: option.stops,
|
||||||
|
travelSegments: option.travelSegments,
|
||||||
|
totalDrivingHours: option.totalDrivingHours,
|
||||||
|
totalDistanceMiles: option.totalDistanceMiles,
|
||||||
|
geographicRationale: option.geographicRationale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sorts and ranks itinerary options based on leisure level preference.
|
/// Sorts and ranks itinerary options based on leisure level preference.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -431,9 +497,17 @@ extension ItineraryOption {
|
|||||||
// Add travel segment to next stop (if not last stop)
|
// Add travel segment to next stop (if not last stop)
|
||||||
if index < travelSegments.count {
|
if index < travelSegments.count {
|
||||||
let segment = travelSegments[index]
|
let segment = travelSegments[index]
|
||||||
// Travel is location-based - just add the segment
|
|
||||||
// Multi-day travel indicated by durationHours > 8
|
|
||||||
timeline.append(.travel(segment))
|
timeline.append(.travel(segment))
|
||||||
|
|
||||||
|
// Insert overnight rest days for multi-day travel segments
|
||||||
|
let overnightRests = calculateOvernightRestDays(
|
||||||
|
for: segment,
|
||||||
|
departingStop: stop,
|
||||||
|
calendar: calendar
|
||||||
|
)
|
||||||
|
for restDay in overnightRests {
|
||||||
|
timeline.append(.rest(restDay))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +553,36 @@ extension ItineraryOption {
|
|||||||
return restDays
|
return restDays
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculates overnight rest days needed during a multi-day travel segment.
|
||||||
|
/// When driving hours exceed a single day (8 hours), rest days are inserted.
|
||||||
|
private func calculateOvernightRestDays(
|
||||||
|
for segment: TravelSegment,
|
||||||
|
departingStop: ItineraryStop,
|
||||||
|
calendar: Calendar
|
||||||
|
) -> [RestDay] {
|
||||||
|
let drivingHours = segment.estimatedDrivingHours
|
||||||
|
let maxDailyHours = 8.0 // Default daily driving limit
|
||||||
|
guard drivingHours > maxDailyHours else { return [] }
|
||||||
|
|
||||||
|
let overnightCount = Int(ceil(drivingHours / maxDailyHours)) - 1
|
||||||
|
guard overnightCount > 0 else { return [] }
|
||||||
|
|
||||||
|
var restDays: [RestDay] = []
|
||||||
|
let departureDay = calendar.startOfDay(for: departingStop.departureDate)
|
||||||
|
|
||||||
|
for dayOffset in 1...overnightCount {
|
||||||
|
guard let restDate = calendar.date(byAdding: .day, value: dayOffset, to: departureDay) else { break }
|
||||||
|
let restDay = RestDay(
|
||||||
|
date: restDate,
|
||||||
|
location: segment.toLocation,
|
||||||
|
notes: "Overnight stop en route to \(segment.toLocation.name)"
|
||||||
|
)
|
||||||
|
restDays.append(restDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return restDays
|
||||||
|
}
|
||||||
|
|
||||||
/// Timeline organized by date for calendar-style display.
|
/// Timeline organized by date for calendar-style display.
|
||||||
/// Note: Travel segments are excluded as they are location-based, not date-based.
|
/// Note: Travel segments are excluded as they are location-based, not date-based.
|
||||||
func timelineByDate() -> [Date: [TimelineItem]] {
|
func timelineByDate() -> [Date: [TimelineItem]] {
|
||||||
|
|||||||
@@ -32,21 +32,19 @@ struct GameTests {
|
|||||||
|
|
||||||
@Test("gameDate returns start of day for dateTime")
|
@Test("gameDate returns start of day for dateTime")
|
||||||
func gameDate_returnsStartOfDay() {
|
func gameDate_returnsStartOfDay() {
|
||||||
let calendar = TestClock.calendar
|
// Use TestFixtures.date which creates dates at 7:05 PM EST — safely same
|
||||||
|
// calendar day in any US timezone when interpreted by Calendar.current.
|
||||||
// Game at 7:05 PM
|
let dateTime = TestFixtures.date(year: 2026, month: 6, day: 15, hour: 19, minute: 5)
|
||||||
let dateTime = calendar.date(from: DateComponents(
|
|
||||||
year: 2026, month: 6, day: 15,
|
|
||||||
hour: 19, minute: 5, second: 0
|
|
||||||
))!
|
|
||||||
|
|
||||||
let game = makeGame(dateTime: dateTime)
|
let game = makeGame(dateTime: dateTime)
|
||||||
|
|
||||||
let expectedStart = calendar.startOfDay(for: dateTime)
|
// Production gameDate uses Calendar.current, so assert with the same calendar
|
||||||
|
let systemCalendar = Calendar.current
|
||||||
|
let expectedStart = systemCalendar.startOfDay(for: dateTime)
|
||||||
#expect(game.gameDate == expectedStart)
|
#expect(game.gameDate == expectedStart)
|
||||||
|
|
||||||
// Verify it's at midnight
|
// Verify it's at midnight in the system calendar
|
||||||
let components = calendar.dateComponents([.hour, .minute, .second], from: game.gameDate)
|
let components = systemCalendar.dateComponents([.hour, .minute, .second], from: game.gameDate)
|
||||||
#expect(components.hour == 0)
|
#expect(components.hour == 0)
|
||||||
#expect(components.minute == 0)
|
#expect(components.minute == 0)
|
||||||
#expect(components.second == 0)
|
#expect(components.second == 0)
|
||||||
@@ -166,16 +164,17 @@ struct GameTests {
|
|||||||
|
|
||||||
@Test("Invariant: gameDate is always at midnight")
|
@Test("Invariant: gameDate is always at midnight")
|
||||||
func invariant_gameDateAtMidnight() {
|
func invariant_gameDateAtMidnight() {
|
||||||
let calendar = TestClock.calendar
|
// Production gameDate uses Calendar.current, so create dates and assert
|
||||||
|
// with Calendar.current to avoid cross-timezone mismatches.
|
||||||
// Test various times throughout the day
|
// Use TestFixtures.date (7pm EST default) to ensure same calendar day in any US tz.
|
||||||
let times = [0, 6, 12, 18, 23].map { hour in
|
let times = [8, 12, 15, 19, 22].map { hour in
|
||||||
calendar.date(from: DateComponents(year: 2026, month: 6, day: 15, hour: hour))!
|
TestFixtures.date(year: 2026, month: 6, day: 15, hour: hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let systemCalendar = Calendar.current
|
||||||
for time in times {
|
for time in times {
|
||||||
let game = makeGame(dateTime: time)
|
let game = makeGame(dateTime: time)
|
||||||
let components = calendar.dateComponents([.hour, .minute, .second], from: game.gameDate)
|
let components = systemCalendar.dateComponents([.hour, .minute, .second], from: game.gameDate)
|
||||||
#expect(components.hour == 0, "gameDate hour should be 0")
|
#expect(components.hour == 0, "gameDate hour should be 0")
|
||||||
#expect(components.minute == 0, "gameDate minute should be 0")
|
#expect(components.minute == 0, "gameDate minute should be 0")
|
||||||
#expect(components.second == 0, "gameDate second should be 0")
|
#expect(components.second == 0, "gameDate second should be 0")
|
||||||
|
|||||||
@@ -124,22 +124,24 @@ struct SportTests {
|
|||||||
|
|
||||||
@Test("isInSeason boundary: first and last day of season month")
|
@Test("isInSeason boundary: first and last day of season month")
|
||||||
func isInSeason_boundaryDays() {
|
func isInSeason_boundaryDays() {
|
||||||
let calendar = TestClock.calendar
|
// Production isInSeason uses Calendar.current to extract month.
|
||||||
|
// Use noon (hour: 12) so the date stays in the correct calendar day
|
||||||
|
// regardless of system timezone across the US.
|
||||||
|
|
||||||
// MLB: First day of March (in season)
|
// MLB: First day of March (in season)
|
||||||
let marchFirst = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1))!
|
let marchFirst = TestFixtures.date(year: 2026, month: 3, day: 1, hour: 12)
|
||||||
#expect(Sport.mlb.isInSeason(for: marchFirst))
|
#expect(Sport.mlb.isInSeason(for: marchFirst))
|
||||||
|
|
||||||
// MLB: Last day of October (in season)
|
// MLB: Last day of October (in season)
|
||||||
let octLast = calendar.date(from: DateComponents(year: 2026, month: 10, day: 31))!
|
let octLast = TestFixtures.date(year: 2026, month: 10, day: 31, hour: 12)
|
||||||
#expect(Sport.mlb.isInSeason(for: octLast))
|
#expect(Sport.mlb.isInSeason(for: octLast))
|
||||||
|
|
||||||
// MLB: First day of November (out of season)
|
// MLB: First day of November (out of season)
|
||||||
let novFirst = calendar.date(from: DateComponents(year: 2026, month: 11, day: 1))!
|
let novFirst = TestFixtures.date(year: 2026, month: 11, day: 1, hour: 12)
|
||||||
#expect(!Sport.mlb.isInSeason(for: novFirst))
|
#expect(!Sport.mlb.isInSeason(for: novFirst))
|
||||||
|
|
||||||
// MLB: Last day of February (out of season)
|
// MLB: Last day of February (out of season)
|
||||||
let febLast = calendar.date(from: DateComponents(year: 2026, month: 2, day: 28))!
|
let febLast = TestFixtures.date(year: 2026, month: 2, day: 28, hour: 12)
|
||||||
#expect(!Sport.mlb.isInSeason(for: febLast))
|
#expect(!Sport.mlb.isInSeason(for: febLast))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ struct TripStopTests {
|
|||||||
|
|
||||||
@Test("formattedDateRange: single date for 1-day stay")
|
@Test("formattedDateRange: single date for 1-day stay")
|
||||||
func formattedDateRange_singleDay() {
|
func formattedDateRange_singleDay() {
|
||||||
let calendar = TestClock.calendar
|
// formattedDateRange uses DateFormatter with system timezone, so create dates
|
||||||
let date = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
|
// at noon to ensure the calendar day is stable across US timezones.
|
||||||
|
let date = TestFixtures.date(year: 2026, month: 6, day: 15, hour: 12)
|
||||||
|
|
||||||
let stop = makeStop(arrivalDate: date, departureDate: date)
|
let stop = makeStop(arrivalDate: date, departureDate: date)
|
||||||
|
|
||||||
@@ -108,9 +109,10 @@ struct TripStopTests {
|
|||||||
|
|
||||||
@Test("formattedDateRange: range for multi-day stay")
|
@Test("formattedDateRange: range for multi-day stay")
|
||||||
func formattedDateRange_multiDay() {
|
func formattedDateRange_multiDay() {
|
||||||
let calendar = TestClock.calendar
|
// formattedDateRange uses DateFormatter with system timezone, so create dates
|
||||||
let arrival = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
|
// at noon to ensure the calendar day is stable across US timezones.
|
||||||
let departure = calendar.date(from: DateComponents(year: 2026, month: 6, day: 18))!
|
let arrival = TestFixtures.date(year: 2026, month: 6, day: 15, hour: 12)
|
||||||
|
let departure = TestFixtures.date(year: 2026, month: 6, day: 18, hour: 12)
|
||||||
|
|
||||||
let stop = makeStop(arrivalDate: arrival, departureDate: departure)
|
let stop = makeStop(arrivalDate: arrival, departureDate: departure)
|
||||||
|
|
||||||
|
|||||||
@@ -64,23 +64,23 @@ struct POITests {
|
|||||||
#expect(justOverPOI.formattedDistance.contains("mi"))
|
#expect(justOverPOI.formattedDistance.contains("mi"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// - Expected Behavior: Zero distance formats correctly
|
/// - Expected Behavior: Zero distance formats as "0 ft"
|
||||||
@Test("formattedDistance: handles zero distance")
|
@Test("formattedDistance: handles zero distance")
|
||||||
func formattedDistance_zero() {
|
func formattedDistance_zero() {
|
||||||
|
// 0 meters = 0 feet, and 0 miles < 0.1 so it uses feet format
|
||||||
|
// String(format: "%.0f ft", 0 * 3.28084) == "0 ft"
|
||||||
let poi = makePOI(distanceMeters: 0)
|
let poi = makePOI(distanceMeters: 0)
|
||||||
let formatted = poi.formattedDistance
|
let formatted = poi.formattedDistance
|
||||||
#expect(formatted.contains("0") || formatted.contains("ft"))
|
#expect(formatted == "0 ft", "Zero distance should format as '0 ft', got '\(formatted)'")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// - Expected Behavior: Large distance formats correctly
|
/// - Expected Behavior: Large distance formats correctly as miles
|
||||||
@Test("formattedDistance: handles large distance")
|
@Test("formattedDistance: handles large distance")
|
||||||
func formattedDistance_large() {
|
func formattedDistance_large() {
|
||||||
// 5000 meters = ~3.1 miles
|
// 5000 meters * 0.000621371 = 3.106855 miles → "3.1 mi"
|
||||||
let poi = makePOI(distanceMeters: 5000)
|
let poi = makePOI(distanceMeters: 5000)
|
||||||
let formatted = poi.formattedDistance
|
let formatted = poi.formattedDistance
|
||||||
|
#expect(formatted == "3.1 mi", "5000m should format as '3.1 mi', got '\(formatted)'")
|
||||||
#expect(formatted.contains("mi"))
|
|
||||||
#expect(formatted.contains("3.1") || formatted.contains("3.") || Double(formatted.replacingOccurrences(of: " mi", with: ""))! > 3.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Invariant Tests
|
// MARK: - Invariant Tests
|
||||||
|
|||||||
@@ -105,6 +105,18 @@ final class ItineraryRowFlatteningTests: XCTestCase {
|
|||||||
// Then: Items should appear in sortOrder: First (1.0), Second (2.0), Third (3.0)
|
// Then: Items should appear in sortOrder: First (1.0), Second (2.0), Third (3.0)
|
||||||
let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0)
|
let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0)
|
||||||
XCTAssertEqual(rowCount, 4, "Expected 4 rows: header + 3 items")
|
XCTAssertEqual(rowCount, 4, "Expected 4 rows: header + 3 items")
|
||||||
|
|
||||||
|
// Verify items are actually sorted by sortOrder (ascending)
|
||||||
|
let rows = controller.flatItems
|
||||||
|
let itemRows = rows.filter { $0.isReorderable }
|
||||||
|
XCTAssertEqual(itemRows.count, 3, "Should have 3 reorderable items")
|
||||||
|
|
||||||
|
// Extract sortOrder values from the custom items
|
||||||
|
let sortOrders: [Double] = itemRows.compactMap {
|
||||||
|
if case .customItem(let item) = $0 { return item.sortOrder }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
XCTAssertEqual(sortOrders, [1.0, 2.0, 3.0], "Items should be in ascending sortOrder: First, Second, Third")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Day Number Calculation Tests
|
// MARK: - Day Number Calculation Tests
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Shared test fixtures and helpers for Itinerary tests.
|
// Shared test fixtures and helpers for Itinerary tests.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import SportsTime
|
@testable import SportsTime
|
||||||
|
|
||||||
@@ -71,13 +72,14 @@ enum ItineraryTestHelpers {
|
|||||||
isPlayoff: false
|
isPlayoff: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let coord = TestFixtures.coordinates[city] ?? CLLocationCoordinate2D(latitude: 40.0, longitude: -80.0)
|
||||||
let stadium = Stadium(
|
let stadium = Stadium(
|
||||||
id: "stadium-\(city)",
|
id: "stadium-\(city)",
|
||||||
name: "\(city) Stadium",
|
name: "\(city) Stadium",
|
||||||
city: city,
|
city: city,
|
||||||
state: "XX",
|
state: "XX",
|
||||||
latitude: 40.0,
|
latitude: coord.latitude,
|
||||||
longitude: -80.0,
|
longitude: coord.longitude,
|
||||||
capacity: 40000,
|
capacity: 40000,
|
||||||
sport: .mlb
|
sport: .mlb
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ struct RegionMapSelectorTests {
|
|||||||
#expect(RegionMapSelector.regionForCoordinate(coord) == .central)
|
#expect(RegionMapSelector.regionForCoordinate(coord) == .central)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Central: Chicago (-87.62)")
|
@Test("Central: Chicago (-87.62) — actually East by longitude boundary")
|
||||||
func central_chicago() {
|
func central_chicago() {
|
||||||
let coord = CLLocationCoordinate2D(latitude: 41.88, longitude: -89.0)
|
let coord = CLLocationCoordinate2D(latitude: 41.88, longitude: -87.6553)
|
||||||
#expect(RegionMapSelector.regionForCoordinate(coord) == .central)
|
#expect(RegionMapSelector.regionForCoordinate(coord) == .east)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Central: exactly at west boundary (-102)")
|
@Test("Central: exactly at west boundary (-102)")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum TestClock {
|
enum TestClock {
|
||||||
static let timeZone = TimeZone.current
|
static let timeZone = TimeZone(identifier: "America/New_York")!
|
||||||
static let locale = Locale(identifier: "en_US_POSIX")
|
static let locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
|
||||||
static let calendar: Calendar = {
|
static let calendar: Calendar = {
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ enum TestFixtures {
|
|||||||
let toCoord = coordinates[to] ?? CLLocationCoordinate2D(latitude: 42.0, longitude: -71.0)
|
let toCoord = coordinates[to] ?? CLLocationCoordinate2D(latitude: 42.0, longitude: -71.0)
|
||||||
|
|
||||||
// Calculate approximate distance (haversine)
|
// Calculate approximate distance (haversine)
|
||||||
let distance = haversineDistance(from: fromCoord, to: toCoord)
|
let distance = haversineDistance(from: fromCoord, to: toCoord) * 1.3
|
||||||
// Estimate driving time at 60 mph average
|
// Estimate driving time at 60 mph average
|
||||||
let duration = distance / 60.0 * 3600.0
|
let duration = distance / 60.0 * 3600.0
|
||||||
|
|
||||||
@@ -467,3 +467,108 @@ extension TestFixtures {
|
|||||||
static let centralCities = ["Chicago", "Houston", "Dallas", "Minneapolis", "Detroit"]
|
static let centralCities = ["Chicago", "Houston", "Dallas", "Minneapolis", "Detroit"]
|
||||||
static let westCoastCities = ["Los Angeles", "San Francisco", "Seattle", "Phoenix"]
|
static let westCoastCities = ["Los Angeles", "San Francisco", "Seattle", "Phoenix"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Messy / Realistic Data Factories
|
||||||
|
|
||||||
|
extension TestFixtures {
|
||||||
|
|
||||||
|
/// Creates games that are all in the past relative to a reference date.
|
||||||
|
static func pastGames(
|
||||||
|
count: Int,
|
||||||
|
sport: Sport = .mlb,
|
||||||
|
cities: [String] = ["New York", "Boston", "Chicago"],
|
||||||
|
referenceDate: Date = TestClock.now
|
||||||
|
) -> [Game] {
|
||||||
|
(0..<count).map { i in
|
||||||
|
let city = cities[i % cities.count]
|
||||||
|
let daysAgo = 30 + (i * 2) // 30-60 days in the past
|
||||||
|
let gameDate = TestClock.calendar.date(byAdding: .day, value: -daysAgo, to: referenceDate)!
|
||||||
|
return game(sport: sport, city: city, dateTime: gameDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a mix of past and future games, returning them categorized.
|
||||||
|
static func mixedPastFutureGames(
|
||||||
|
pastCount: Int = 5,
|
||||||
|
futureCount: Int = 5,
|
||||||
|
sport: Sport = .mlb,
|
||||||
|
cities: [String] = ["New York", "Boston", "Chicago", "Philadelphia"],
|
||||||
|
referenceDate: Date = TestClock.now
|
||||||
|
) -> (past: [Game], future: [Game], all: [Game]) {
|
||||||
|
let past = (0..<pastCount).map { i in
|
||||||
|
let city = cities[i % cities.count]
|
||||||
|
let gameDate = TestClock.calendar.date(byAdding: .day, value: -(i + 1) * 5, to: referenceDate)!
|
||||||
|
return game(id: "past_\(i)", sport: sport, city: city, dateTime: gameDate)
|
||||||
|
}
|
||||||
|
let future = (0..<futureCount).map { i in
|
||||||
|
let city = cities[i % cities.count]
|
||||||
|
let gameDate = TestClock.calendar.date(byAdding: .day, value: (i + 1) * 2, to: referenceDate)!
|
||||||
|
return game(id: "future_\(i)", sport: sport, city: city, dateTime: gameDate)
|
||||||
|
}
|
||||||
|
return (past, future, past + future)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates games from sports the user didn't select (for testing sport filtering).
|
||||||
|
static func gamesWithWrongSport(
|
||||||
|
selectedSport: Sport = .mlb,
|
||||||
|
wrongSport: Sport = .nba,
|
||||||
|
correctCount: Int = 3,
|
||||||
|
wrongCount: Int = 3,
|
||||||
|
cities: [String] = ["New York", "Boston", "Chicago"]
|
||||||
|
) -> (correct: [Game], wrong: [Game], all: [Game]) {
|
||||||
|
let start = TestClock.addingDays(1)
|
||||||
|
let correct = (0..<correctCount).map { i in
|
||||||
|
let city = cities[i % cities.count]
|
||||||
|
let dt = TestClock.calendar.date(byAdding: .day, value: i, to: start)!
|
||||||
|
return game(id: "correct_\(i)", sport: selectedSport, city: city, dateTime: dt)
|
||||||
|
}
|
||||||
|
let wrong = (0..<wrongCount).map { i in
|
||||||
|
let city = cities[i % cities.count]
|
||||||
|
let dt = TestClock.calendar.date(byAdding: .day, value: i, to: start)!
|
||||||
|
return game(id: "wrong_\(i)", sport: wrongSport, city: city, dateTime: dt)
|
||||||
|
}
|
||||||
|
return (correct, wrong, correct + wrong)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates games referencing stadium IDs that don't exist in any stadium map.
|
||||||
|
static func gamesWithMissingStadium(
|
||||||
|
count: Int = 3,
|
||||||
|
sport: Sport = .mlb
|
||||||
|
) -> [Game] {
|
||||||
|
let start = TestClock.addingDays(1)
|
||||||
|
return (0..<count).map { i in
|
||||||
|
let dt = TestClock.calendar.date(byAdding: .day, value: i, to: start)!
|
||||||
|
return game(
|
||||||
|
id: "orphan_\(i)",
|
||||||
|
sport: sport,
|
||||||
|
city: "Atlantis",
|
||||||
|
dateTime: dt,
|
||||||
|
stadiumId: "stadium_nonexistent_\(i)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates two different games sharing the same ID (simulates rescheduled games).
|
||||||
|
static func duplicateIdGames(sport: Sport = .mlb) -> [Game] {
|
||||||
|
let dt1 = TestClock.addingDays(2)
|
||||||
|
let dt2 = TestClock.addingDays(3)
|
||||||
|
return [
|
||||||
|
game(id: "dup_game_001", sport: sport, city: "New York", dateTime: dt1),
|
||||||
|
game(id: "dup_game_001", sport: sport, city: "Boston", dateTime: dt2),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates games spread over many days for long-trip duration testing.
|
||||||
|
static func longTripGames(
|
||||||
|
days: Int = 30,
|
||||||
|
sport: Sport = .mlb,
|
||||||
|
cities: [String] = ["New York", "Boston", "Chicago", "Philadelphia", "Atlanta"]
|
||||||
|
) -> [Game] {
|
||||||
|
let start = TestClock.addingDays(1)
|
||||||
|
return (0..<days).map { i in
|
||||||
|
let city = cities[i % cities.count]
|
||||||
|
let dt = TestClock.calendar.date(byAdding: .day, value: i, to: start)!
|
||||||
|
return game(id: "long_\(i)", sport: sport, city: city, dateTime: dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -531,10 +531,273 @@ struct GameDAGRouterTests {
|
|||||||
constraints: constraints
|
constraints: constraints
|
||||||
)
|
)
|
||||||
|
|
||||||
// May return routes with just game1, or empty
|
// Multi-game routes should not include games with missing stadiums
|
||||||
#expect(routes.allSatisfy { route in
|
// (the router can't build transitions without stadium coordinates).
|
||||||
route.allSatisfy { game in stadiums[game.stadiumId] != nil || game.id == game2.id }
|
// Single-game routes may still include them since no transition is needed.
|
||||||
})
|
for route in routes where route.count > 1 {
|
||||||
|
for game in route {
|
||||||
|
#expect(stadiums[game.stadiumId] != nil,
|
||||||
|
"Multi-game route should not include games with missing stadiums")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The router should still return routes (at least the valid single-game route)
|
||||||
|
#expect(!routes.isEmpty, "Should return at least the valid game as a single-game route")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Preference Tests
|
||||||
|
|
||||||
|
@Test("routePreference: direct prefers lower mileage routes")
|
||||||
|
func routePreference_direct_prefersLowerMileageRoutes() {
|
||||||
|
// Create games spread across cities at varying distances
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
|
||||||
|
// Create games: nearby (NYC, Boston, Philly) and far (Chicago, LA)
|
||||||
|
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord)
|
||||||
|
let game2Date = calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord)
|
||||||
|
let game3Date = calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
let (game3, stadium3) = makeGameAndStadium(city: "Philadelphia", date: game3Date, coord: phillyCoord)
|
||||||
|
let game4Date = calendar.date(byAdding: .day, value: 3, to: baseDate)!
|
||||||
|
let (game4, stadium4) = makeGameAndStadium(city: "Chicago", date: game4Date, coord: chicagoCoord)
|
||||||
|
let game5Date = calendar.date(byAdding: .day, value: 5, to: baseDate)!
|
||||||
|
let (game5, stadium5) = makeGameAndStadium(city: "Los Angeles", date: game5Date, coord: laCoord)
|
||||||
|
|
||||||
|
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3, stadium4.id: stadium4, stadium5.id: stadium5]
|
||||||
|
let games = [game1, game2, game3, game4, game5]
|
||||||
|
|
||||||
|
let directRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .direct
|
||||||
|
)
|
||||||
|
let scenicRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .scenic
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direct routes should exist
|
||||||
|
#expect(!directRoutes.isEmpty)
|
||||||
|
#expect(!scenicRoutes.isEmpty)
|
||||||
|
|
||||||
|
// Compare the first route from each: direct should have lower or equal total miles
|
||||||
|
if let directFirst = directRoutes.first, let scenicFirst = scenicRoutes.first {
|
||||||
|
let directMiles = totalMiles(for: directFirst, stadiums: stadiums)
|
||||||
|
let scenicMiles = totalMiles(for: scenicFirst, stadiums: stadiums)
|
||||||
|
// Direct should tend toward lower mileage routes being ranked first
|
||||||
|
#expect(directMiles <= scenicMiles, "Direct first route (\(Int(directMiles))mi) should be <= scenic first route (\(Int(scenicMiles))mi)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("routePreference: scenic prefers more cities")
|
||||||
|
func routePreference_scenic_prefersMoreCitiesRoutes() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
|
||||||
|
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord)
|
||||||
|
let game2Date = calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord)
|
||||||
|
let game3Date = calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
let (game3, stadium3) = makeGameAndStadium(city: "Philadelphia", date: game3Date, coord: phillyCoord)
|
||||||
|
let game4Date = calendar.date(byAdding: .day, value: 3, to: baseDate)!
|
||||||
|
let (game4, stadium4) = makeGameAndStadium(city: "Chicago", date: game4Date, coord: chicagoCoord)
|
||||||
|
|
||||||
|
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3, stadium4.id: stadium4]
|
||||||
|
let games = [game1, game2, game3, game4]
|
||||||
|
|
||||||
|
let scenicRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .scenic
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!scenicRoutes.isEmpty)
|
||||||
|
// Scenic routes should have routes with multiple cities
|
||||||
|
let maxCities = scenicRoutes.map { route in
|
||||||
|
Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||||
|
}.max() ?? 0
|
||||||
|
#expect(maxCities >= 2, "Scenic should produce multi-city routes")
|
||||||
|
|
||||||
|
let directRoutes2 = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .direct
|
||||||
|
)
|
||||||
|
if let sFirst = scenicRoutes.first, let dFirst = directRoutes2.first {
|
||||||
|
let sCities = Set(sFirst.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||||
|
let dCities = Set(dFirst.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||||
|
#expect(sCities >= dCities, "Scenic first route should have >= cities than direct first route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("routePreference: balanced matches default behavior")
|
||||||
|
func routePreference_balanced_matchesDefault() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
|
||||||
|
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord)
|
||||||
|
let game2Date = calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord)
|
||||||
|
|
||||||
|
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]
|
||||||
|
let games = [game1, game2]
|
||||||
|
|
||||||
|
let balancedRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .balanced
|
||||||
|
)
|
||||||
|
let defaultRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both should produce the same routes (balanced is default)
|
||||||
|
#expect(balancedRoutes.count == defaultRoutes.count)
|
||||||
|
|
||||||
|
if let bFirst = balancedRoutes.first, let dFirst = defaultRoutes.first {
|
||||||
|
let bIds = bFirst.map { $0.id }
|
||||||
|
let dIds = dFirst.map { $0.id }
|
||||||
|
#expect(bIds == dIds, "Balanced and default should produce identical first route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Preference Scoring Tests
|
||||||
|
|
||||||
|
@Test("routePreference: direct ranks lowest-mileage routes first overall")
|
||||||
|
func routePreference_direct_ranksLowestMileageFirst() {
|
||||||
|
// Create a spread of games across East Coast + distant cities
|
||||||
|
// With enough games, the router produces diverse routes.
|
||||||
|
// Direct should surface low-mileage routes at the top.
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
|
||||||
|
var games: [Game] = []
|
||||||
|
var stadiums: [String: Stadium] = [:]
|
||||||
|
|
||||||
|
let cityData: [(String, CLLocationCoordinate2D)] = [
|
||||||
|
("New York", nycCoord),
|
||||||
|
("Philadelphia", phillyCoord),
|
||||||
|
("Boston", bostonCoord),
|
||||||
|
("Chicago", chicagoCoord),
|
||||||
|
("Seattle", seattleCoord),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (dayOffset, (city, coord)) in cityData.enumerated() {
|
||||||
|
let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate)!
|
||||||
|
let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord)
|
||||||
|
games.append(game)
|
||||||
|
stadiums[stadium.id] = stadium
|
||||||
|
}
|
||||||
|
|
||||||
|
let directRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .direct
|
||||||
|
)
|
||||||
|
let scenicRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .scenic
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!directRoutes.isEmpty)
|
||||||
|
#expect(!scenicRoutes.isEmpty)
|
||||||
|
|
||||||
|
// Direct first route should have <= miles than scenic first route
|
||||||
|
if let dFirst = directRoutes.first, let sFirst = scenicRoutes.first {
|
||||||
|
let dMiles = totalMiles(for: dFirst, stadiums: stadiums)
|
||||||
|
let sMiles = totalMiles(for: sFirst, stadiums: stadiums)
|
||||||
|
#expect(dMiles <= sMiles, "Direct first route (\(Int(dMiles))mi) should be <= scenic first route (\(Int(sMiles))mi)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("routePreference: scenic ranks more-cities routes first overall")
|
||||||
|
func routePreference_scenic_ranksMoreCitiesFirst() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
|
||||||
|
var games: [Game] = []
|
||||||
|
var stadiums: [String: Stadium] = [:]
|
||||||
|
|
||||||
|
let cityData: [(String, CLLocationCoordinate2D)] = [
|
||||||
|
("New York", nycCoord),
|
||||||
|
("Philadelphia", phillyCoord),
|
||||||
|
("Boston", bostonCoord),
|
||||||
|
("Chicago", chicagoCoord),
|
||||||
|
("Seattle", seattleCoord),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (dayOffset, (city, coord)) in cityData.enumerated() {
|
||||||
|
let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate)!
|
||||||
|
let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord)
|
||||||
|
games.append(game)
|
||||||
|
stadiums[stadium.id] = stadium
|
||||||
|
}
|
||||||
|
|
||||||
|
let scenicRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .scenic
|
||||||
|
)
|
||||||
|
let directRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .direct
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!scenicRoutes.isEmpty)
|
||||||
|
#expect(!directRoutes.isEmpty)
|
||||||
|
|
||||||
|
// Scenic first route should have >= cities than direct first route
|
||||||
|
if let sFirst = scenicRoutes.first, let dFirst = directRoutes.first {
|
||||||
|
let sCities = Set(sFirst.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||||
|
let dCities = Set(dFirst.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||||
|
#expect(sCities >= dCities, "Scenic first route (\(sCities) cities) should be >= direct first route (\(dCities) cities)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("routePreference: different preferences produce different route ordering")
|
||||||
|
func routePreference_differentPreferences_produceDifferentOrdering() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
|
||||||
|
var games: [Game] = []
|
||||||
|
var stadiums: [String: Stadium] = [:]
|
||||||
|
|
||||||
|
// Create enough games across varied distances to force diverse options
|
||||||
|
let cityData: [(String, CLLocationCoordinate2D)] = [
|
||||||
|
("New York", nycCoord),
|
||||||
|
("Philadelphia", phillyCoord),
|
||||||
|
("Boston", bostonCoord),
|
||||||
|
("Chicago", chicagoCoord),
|
||||||
|
("Los Angeles", laCoord),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (dayOffset, (city, coord)) in cityData.enumerated() {
|
||||||
|
let date = calendar.date(byAdding: .day, value: dayOffset * 2, to: baseDate)!
|
||||||
|
let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord)
|
||||||
|
games.append(game)
|
||||||
|
stadiums[stadium.id] = stadium
|
||||||
|
}
|
||||||
|
|
||||||
|
let directRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .direct
|
||||||
|
)
|
||||||
|
let scenicRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .scenic
|
||||||
|
)
|
||||||
|
let balancedRoutes = GameDAGRouter.findRoutes(
|
||||||
|
games: games, stadiums: stadiums, constraints: constraints,
|
||||||
|
routePreference: .balanced
|
||||||
|
)
|
||||||
|
|
||||||
|
// All three should produce routes
|
||||||
|
#expect(!directRoutes.isEmpty)
|
||||||
|
#expect(!scenicRoutes.isEmpty)
|
||||||
|
#expect(!balancedRoutes.isEmpty)
|
||||||
|
|
||||||
|
// With enough variety, at least two of the three should differ in first-route
|
||||||
|
let dKey = directRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? ""
|
||||||
|
let sKey = scenicRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? ""
|
||||||
|
let bKey = balancedRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? ""
|
||||||
|
|
||||||
|
// With enough routes, average mileage should differ by preference
|
||||||
|
// Direct should have lower average mileage in top routes than scenic
|
||||||
|
if directRoutes.count >= 2 && scenicRoutes.count >= 2 {
|
||||||
|
let directAvgMiles = directRoutes.prefix(3).map { totalMiles(for: $0, stadiums: stadiums) }.reduce(0, +) / Double(min(3, directRoutes.count))
|
||||||
|
let scenicAvgMiles = scenicRoutes.prefix(3).map { totalMiles(for: $0, stadiums: stadiums) }.reduce(0, +) / Double(min(3, scenicRoutes.count))
|
||||||
|
#expect(directAvgMiles <= scenicAvgMiles,
|
||||||
|
"Direct top routes (\(Int(directAvgMiles))mi avg) should have <= mileage than scenic (\(Int(scenicAvgMiles))mi avg)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
@@ -601,4 +864,17 @@ struct GameDAGRouterTests {
|
|||||||
isPlayoff: false
|
isPlayoff: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func totalMiles(for route: [Game], stadiums: [String: Stadium]) -> Double {
|
||||||
|
var total: Double = 0
|
||||||
|
for i in 0..<(route.count - 1) {
|
||||||
|
guard let from = stadiums[route[i].stadiumId],
|
||||||
|
let to = stadiums[route[i+1].stadiumId] else { continue }
|
||||||
|
total += TravelEstimator.haversineDistanceMiles(
|
||||||
|
from: from.coordinate,
|
||||||
|
to: to.coordinate
|
||||||
|
) * 1.3
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1001
SportsTimeTests/Planning/ImprovementPlanTDDTests.swift
Normal file
1001
SportsTimeTests/Planning/ImprovementPlanTDDTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -60,8 +60,8 @@ struct ItineraryBuilderTests {
|
|||||||
#expect(result != nil)
|
#expect(result != nil)
|
||||||
#expect(result?.stops.count == 2)
|
#expect(result?.stops.count == 2)
|
||||||
#expect(result?.travelSegments.count == 1)
|
#expect(result?.travelSegments.count == 1)
|
||||||
#expect(result?.totalDrivingHours ?? 0 > 0)
|
#expect((3.0...6.0).contains(result?.totalDrivingHours ?? 0), "NYC→Boston should be 3-6 hours driving")
|
||||||
#expect(result?.totalDistanceMiles ?? 0 > 0)
|
#expect((200...400).contains(result?.totalDistanceMiles ?? 0), "NYC→Boston should be 200-400 road miles")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("build: three stops creates two segments")
|
@Test("build: three stops creates two segments")
|
||||||
@@ -303,16 +303,15 @@ struct ItineraryBuilderTests {
|
|||||||
|
|
||||||
// MARK: - Edge Case Tests
|
// MARK: - Edge Case Tests
|
||||||
|
|
||||||
@Test("Edge: stops with nil coordinates use fallback")
|
@Test("Edge: stops with nil coordinates are infeasible")
|
||||||
func edge_nilCoordinates_useFallback() {
|
func edge_nilCoordinates_infeasible() {
|
||||||
let stop1 = makeStop(city: "City1", coordinate: nil)
|
let stop1 = makeStop(city: "City1", coordinate: nil)
|
||||||
let stop2 = makeStop(city: "City2", coordinate: nil)
|
let stop2 = makeStop(city: "City2", coordinate: nil)
|
||||||
|
|
||||||
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
|
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
|
||||||
|
|
||||||
// Should use fallback distance (300 miles)
|
// Missing coordinates = infeasible (safer to skip than show wrong drive time)
|
||||||
#expect(result != nil)
|
#expect(result == nil, "Stops with missing coordinates should be infeasible")
|
||||||
#expect(result?.totalDistanceMiles ?? 0 > 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Edge: same city stops have zero distance")
|
@Test("Edge: same city stops have zero distance")
|
||||||
|
|||||||
254
SportsTimeTests/Planning/MustStopValidationTests.swift
Normal file
254
SportsTimeTests/Planning/MustStopValidationTests.swift
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
//
|
||||||
|
// MustStopValidationTests.swift
|
||||||
|
// SportsTimeTests
|
||||||
|
//
|
||||||
|
// Tests for must-stop location filtering across all scenario planners.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import CoreLocation
|
||||||
|
@testable import SportsTime
|
||||||
|
|
||||||
|
@Suite("Must-Stop Validation")
|
||||||
|
struct MustStopValidationTests {
|
||||||
|
|
||||||
|
// MARK: - Centralized Must-Stop Filter (TripPlanningEngine)
|
||||||
|
|
||||||
|
@Test("scenarioA: must stops filter routes to include required cities")
|
||||||
|
func scenarioA_mustStops_routesContainRequiredCities() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
|
||||||
|
let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate)
|
||||||
|
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day2)
|
||||||
|
let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day3)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS, gamePHL])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day3,
|
||||||
|
mustStopLocations: [LocationInput(name: "Boston")]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameNYC, gameBOS, gamePHL],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { $0.city.lowercased() }
|
||||||
|
#expect(cities.contains("boston"), "Every route must include Boston as a must-stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("must stop impossible city returns failure")
|
||||||
|
func mustStops_impossibleCity_returnsFailure() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
|
||||||
|
let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate)
|
||||||
|
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day2)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS])
|
||||||
|
|
||||||
|
// Require a city with no games
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day2,
|
||||||
|
mustStopLocations: [LocationInput(name: "Denver")]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameNYC, gameBOS],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Should fail because no route can include Denver
|
||||||
|
#expect(!result.isSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("scenarioB: must stops enforced via centralized filter")
|
||||||
|
func scenarioB_mustStops_routesContainRequiredCities() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
|
||||||
|
let gameNYC = TestFixtures.game(id: "must_see_1", city: "New York", dateTime: baseDate)
|
||||||
|
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day2)
|
||||||
|
let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day3)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS, gamePHL])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .gameFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
mustSeeGameIds: [gameNYC.id],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day3,
|
||||||
|
mustStopLocations: [LocationInput(name: "Boston")]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameNYC, gameBOS, gamePHL],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { $0.city.lowercased() }
|
||||||
|
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("scenarioD: must stops enforced via centralized filter")
|
||||||
|
func scenarioD_mustStops_routesContainRequiredCities() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
|
||||||
|
let teamId = "team_mlb_new_york"
|
||||||
|
let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate, homeTeamId: teamId)
|
||||||
|
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day2, homeTeamId: "team_mlb_boston", awayTeamId: teamId)
|
||||||
|
let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day3, homeTeamId: "team_mlb_philadelphia", awayTeamId: teamId)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS, gamePHL])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .followTeam,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day3,
|
||||||
|
mustStopLocations: [LocationInput(name: "Boston")],
|
||||||
|
followTeamId: teamId
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameNYC, gameBOS, gamePHL],
|
||||||
|
teams: [teamId: TestFixtures.team(city: "New York")],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { $0.city.lowercased() }
|
||||||
|
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in follow-team mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("scenarioE: must stops enforced via centralized filter")
|
||||||
|
func scenarioE_mustStops_routesContainRequiredCities() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
// 2 teams → windowDuration = 4 days. Games must be within 3 days to fit in a single window.
|
||||||
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
|
||||||
|
|
||||||
|
let teamNYC = "team_mlb_new_york"
|
||||||
|
let teamBOS = "team_mlb_boston"
|
||||||
|
|
||||||
|
let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate, homeTeamId: teamNYC)
|
||||||
|
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day3, homeTeamId: teamBOS)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day3,
|
||||||
|
mustStopLocations: [LocationInput(name: "Boston")],
|
||||||
|
selectedTeamIds: [teamNYC, teamBOS]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameNYC, gameBOS],
|
||||||
|
teams: [
|
||||||
|
teamNYC: TestFixtures.team(city: "New York"),
|
||||||
|
teamBOS: TestFixtures.team(city: "Boston"),
|
||||||
|
],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { $0.city.lowercased() }
|
||||||
|
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in team-first mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("scenarioC: must stops enforced via centralized filter")
|
||||||
|
func scenarioC_mustStops_routesContainRequiredCities() {
|
||||||
|
// Route: Chicago → New York (eastward). Detroit is directionally between them.
|
||||||
|
let baseDate = TestClock.now
|
||||||
|
let endDate = TestClock.calendar.date(byAdding: .day, value: 10, to: baseDate)!
|
||||||
|
|
||||||
|
let chiCoord = TestFixtures.coordinates["Chicago"]!
|
||||||
|
let detCoord = TestFixtures.coordinates["Detroit"]!
|
||||||
|
let nycCoord = TestFixtures.coordinates["New York"]!
|
||||||
|
|
||||||
|
let chiStadium = TestFixtures.stadium(id: "chi", city: "Chicago")
|
||||||
|
let detStadium = TestFixtures.stadium(id: "det", city: "Detroit")
|
||||||
|
let nycStadium = TestFixtures.stadium(id: "nyc", city: "New York")
|
||||||
|
|
||||||
|
let gameCHI = TestFixtures.game(city: "Chicago", dateTime: TestClock.addingDays(1), stadiumId: "chi")
|
||||||
|
let gameDET = TestFixtures.game(city: "Detroit", dateTime: TestClock.addingDays(4), stadiumId: "det")
|
||||||
|
let gameNYC = TestFixtures.game(city: "New York", dateTime: TestClock.addingDays(7), stadiumId: "nyc")
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .locations,
|
||||||
|
startLocation: LocationInput(name: "Chicago", coordinate: chiCoord),
|
||||||
|
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: endDate,
|
||||||
|
leisureLevel: .moderate,
|
||||||
|
mustStopLocations: [LocationInput(name: "Detroit")],
|
||||||
|
lodgingType: .hotel,
|
||||||
|
numberOfDrivers: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameCHI, gameDET, gameNYC],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: ["chi": chiStadium, "det": detStadium, "nyc": nycStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { $0.city.lowercased() }
|
||||||
|
#expect(cities.contains("detroit"), "Must-stop filter should ensure Detroit is included in Chicago→NYC route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1098
SportsTimeTests/Planning/PlannerOutputSanityTests.swift
Normal file
1098
SportsTimeTests/Planning/PlannerOutputSanityTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
560
SportsTimeTests/Planning/PlanningHardeningTests.swift
Normal file
560
SportsTimeTests/Planning/PlanningHardeningTests.swift
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
//
|
||||||
|
// PlanningHardeningTests.swift
|
||||||
|
// SportsTimeTests
|
||||||
|
//
|
||||||
|
// Phase 3: Test hardening — timezone edge cases, driving constraint boundaries,
|
||||||
|
// filter cascades, anchor constraints, and constraint interactions.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
@testable import SportsTime
|
||||||
|
|
||||||
|
// MARK: - 3A: Timezone Edge Cases
|
||||||
|
|
||||||
|
@Suite("Timezone Edge Cases")
|
||||||
|
struct TimezoneEdgeCaseTests {
|
||||||
|
|
||||||
|
@Test("Game near midnight in Eastern shows on correct calendar day")
|
||||||
|
func game_nearMidnight_eastern_correctDay() {
|
||||||
|
// Use Eastern timezone calendar for consistent results regardless of machine timezone
|
||||||
|
var etCalendar = Calendar.current
|
||||||
|
etCalendar.timeZone = TimeZone(identifier: "America/New_York")!
|
||||||
|
|
||||||
|
// 11:30 PM ET — should be on July 15, not July 16
|
||||||
|
var components = DateComponents()
|
||||||
|
components.year = 2026
|
||||||
|
components.month = 7
|
||||||
|
components.day = 15
|
||||||
|
components.hour = 23
|
||||||
|
components.minute = 30
|
||||||
|
components.timeZone = TimeZone(identifier: "America/New_York")
|
||||||
|
let lateGame = etCalendar.date(from: components)!
|
||||||
|
|
||||||
|
let game = TestFixtures.game(city: "New York", dateTime: lateGame)
|
||||||
|
let dayOfGame = etCalendar.startOfDay(for: game.startTime)
|
||||||
|
|
||||||
|
var expectedComponents = DateComponents()
|
||||||
|
expectedComponents.year = 2026
|
||||||
|
expectedComponents.month = 7
|
||||||
|
expectedComponents.day = 15
|
||||||
|
expectedComponents.timeZone = TimeZone(identifier: "America/New_York")
|
||||||
|
let expectedDay = etCalendar.startOfDay(for: etCalendar.date(from: expectedComponents)!)
|
||||||
|
|
||||||
|
#expect(dayOfGame == expectedDay, "Late-night game should be on the same calendar day in Eastern")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cross-timezone travel: game times compared in UTC")
|
||||||
|
func crossTimezone_gameTimesComparedInUTC() {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
// Game 1: 7 PM ET in New York
|
||||||
|
var comp1 = DateComponents()
|
||||||
|
comp1.year = 2026; comp1.month = 7; comp1.day = 15
|
||||||
|
comp1.hour = 19; comp1.minute = 0
|
||||||
|
comp1.timeZone = TimeZone(identifier: "America/New_York")
|
||||||
|
let game1Time = calendar.date(from: comp1)!
|
||||||
|
|
||||||
|
// Game 2: 7 PM CT in Chicago (= 8 PM ET, 1 hour later)
|
||||||
|
var comp2 = DateComponents()
|
||||||
|
comp2.year = 2026; comp2.month = 7; comp2.day = 15
|
||||||
|
comp2.hour = 19; comp2.minute = 0
|
||||||
|
comp2.timeZone = TimeZone(identifier: "America/Chicago")
|
||||||
|
let game2Time = calendar.date(from: comp2)!
|
||||||
|
|
||||||
|
// Game 2 is AFTER game 1 in absolute time
|
||||||
|
#expect(game2Time > game1Time, "Chicago 7PM should be after NYC 7PM in absolute time")
|
||||||
|
|
||||||
|
let game1 = TestFixtures.game(city: "New York", dateTime: game1Time)
|
||||||
|
let game2 = TestFixtures.game(city: "Chicago", dateTime: game2Time)
|
||||||
|
|
||||||
|
#expect(game2.startTime > game1.startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Day bucketing consistent across timezone boundaries")
|
||||||
|
func dayBucketing_consistentAcrossTimezones() {
|
||||||
|
// Use Eastern timezone calendar for consistent results
|
||||||
|
var etCalendar = Calendar.current
|
||||||
|
etCalendar.timeZone = TimeZone(identifier: "America/New_York")!
|
||||||
|
|
||||||
|
// Two games: one at 11 PM ET, one at 12:30 AM ET next day
|
||||||
|
var comp1 = DateComponents()
|
||||||
|
comp1.year = 2026; comp1.month = 7; comp1.day = 15
|
||||||
|
comp1.hour = 23; comp1.minute = 0
|
||||||
|
comp1.timeZone = TimeZone(identifier: "America/New_York")
|
||||||
|
let lateGame = etCalendar.date(from: comp1)!
|
||||||
|
|
||||||
|
var comp2 = DateComponents()
|
||||||
|
comp2.year = 2026; comp2.month = 7; comp2.day = 16
|
||||||
|
comp2.hour = 0; comp2.minute = 30
|
||||||
|
comp2.timeZone = TimeZone(identifier: "America/New_York")
|
||||||
|
let earlyGame = etCalendar.date(from: comp2)!
|
||||||
|
|
||||||
|
let day1 = etCalendar.startOfDay(for: lateGame)
|
||||||
|
let day2 = etCalendar.startOfDay(for: earlyGame)
|
||||||
|
|
||||||
|
#expect(day1 != day2, "Games across midnight should be on different calendar days")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 3B: Driving Constraint Boundaries
|
||||||
|
|
||||||
|
@Suite("Driving Constraint Boundaries")
|
||||||
|
struct DrivingConstraintBoundaryTests {
|
||||||
|
|
||||||
|
@Test("DrivingConstraints: exactly at max daily hours is feasible")
|
||||||
|
func exactlyAtMaxDailyHours_isFeasible() {
|
||||||
|
let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
let nyc = TestFixtures.coordinates["New York"]!
|
||||||
|
let boston = TestFixtures.coordinates["Boston"]!
|
||||||
|
|
||||||
|
let from = ItineraryStop(
|
||||||
|
city: "New York", state: "NY", coordinate: nyc,
|
||||||
|
games: [], arrivalDate: TestClock.now, departureDate: TestClock.now,
|
||||||
|
location: LocationInput(name: "New York", coordinate: nyc), firstGameStart: nil
|
||||||
|
)
|
||||||
|
let to = ItineraryStop(
|
||||||
|
city: "Boston", state: "MA", coordinate: boston,
|
||||||
|
games: [], arrivalDate: TestClock.now, departureDate: TestClock.now,
|
||||||
|
location: LocationInput(name: "Boston", coordinate: boston), firstGameStart: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// NYC to Boston is ~250 road miles / 60 mph = ~4.2 hours, well under 8
|
||||||
|
let segment = TravelEstimator.estimate(from: from, to: to, constraints: constraints)
|
||||||
|
#expect(segment != nil, "NYC to Boston should be feasible with 8-hour limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DrivingConstraints: minimum 1 driver always enforced")
|
||||||
|
func minimumOneDriver_alwaysEnforced() {
|
||||||
|
let constraints = DrivingConstraints(numberOfDrivers: 0, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
#expect(constraints.numberOfDrivers == 1)
|
||||||
|
#expect(constraints.maxDailyDrivingHours == 8.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DrivingConstraints: minimum 1 hour per driver always enforced")
|
||||||
|
func minimumOneHour_alwaysEnforced() {
|
||||||
|
let constraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 0.0)
|
||||||
|
#expect(constraints.maxHoursPerDriverPerDay == 1.0)
|
||||||
|
#expect(constraints.maxDailyDrivingHours == 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DrivingConstraints: negative values clamped")
|
||||||
|
func negativeValues_clamped() {
|
||||||
|
let constraints = DrivingConstraints(numberOfDrivers: -3, maxHoursPerDriverPerDay: -10.0)
|
||||||
|
#expect(constraints.numberOfDrivers >= 1)
|
||||||
|
#expect(constraints.maxHoursPerDriverPerDay >= 1.0)
|
||||||
|
#expect(constraints.maxDailyDrivingHours >= 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Overnight stop required for long segments")
|
||||||
|
func overnightStop_requiredForLongSegments() {
|
||||||
|
let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
let shortSegment = TestFixtures.travelSegment(from: "New York", to: "Boston")
|
||||||
|
let longSegment = TestFixtures.travelSegment(from: "New York", to: "Chicago")
|
||||||
|
|
||||||
|
let shortNeedsOvernight = TravelEstimator.requiresOvernightStop(
|
||||||
|
segment: shortSegment, constraints: constraints
|
||||||
|
)
|
||||||
|
let longNeedsOvernight = TravelEstimator.requiresOvernightStop(
|
||||||
|
segment: longSegment, constraints: constraints
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!shortNeedsOvernight, "NYC→Boston (~4h) should not need overnight")
|
||||||
|
#expect(longNeedsOvernight, "NYC→Chicago (~13h) should need overnight")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 3C: Filter Cascades
|
||||||
|
|
||||||
|
@Suite("Filter Cascades")
|
||||||
|
struct FilterCascadeTests {
|
||||||
|
|
||||||
|
@Test("All options eliminated by repeat city filter → clear error")
|
||||||
|
func allEliminatedByRepeatCity_clearsError() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
|
||||||
|
// Both games in same city, different days → repeat city violation
|
||||||
|
let game1 = TestFixtures.game(city: "New York", dateTime: baseDate)
|
||||||
|
let game2 = TestFixtures.game(city: "New York", dateTime: day2)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day2,
|
||||||
|
allowRepeatCities: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [game1, game2],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
break // Success is also acceptable — engine found a valid non-repeating route
|
||||||
|
case .failure(let failure):
|
||||||
|
// Should get either repeatCityViolation or noGamesInRange/noValidRoutes
|
||||||
|
let isExpectedFailure = failure.reason == .repeatCityViolation(cities: ["New York"])
|
||||||
|
|| failure.reason == .noValidRoutes
|
||||||
|
|| failure.reason == .noGamesInRange
|
||||||
|
#expect(isExpectedFailure, "Should get a clear failure reason, got: \(failure.message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Must-stop filter with impossible city → clear error")
|
||||||
|
func mustStopImpossibleCity_clearError() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
|
||||||
|
let game1 = TestFixtures.game(city: "New York", dateTime: baseDate)
|
||||||
|
let game2 = TestFixtures.game(city: "Boston", dateTime: day2)
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day2,
|
||||||
|
mustStopLocations: [LocationInput(name: "Atlantis")]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [game1, game2],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .failure(let failure) = result else { Issue.record("Expected .failure, got \(result)"); return }
|
||||||
|
#expect(failure.violations.contains(where: { $0.type == .mustStop }),
|
||||||
|
"Should have mustStop violation")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty sports set produces warning")
|
||||||
|
func emptySportsSet_producesWarning() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let endDate = TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: [:]
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
_ = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
#expect(engine.warnings.contains(where: { $0.type == .missingData }),
|
||||||
|
"Empty sports set should produce a warning")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Filters are idempotent: double-filtering produces same result")
|
||||||
|
func filters_idempotent() {
|
||||||
|
let stop1 = ItineraryStop(
|
||||||
|
city: "New York", state: "NY",
|
||||||
|
coordinate: TestFixtures.coordinates["New York"],
|
||||||
|
games: ["g1"],
|
||||||
|
arrivalDate: TestClock.now,
|
||||||
|
departureDate: TestClock.addingDays(1),
|
||||||
|
location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
|
||||||
|
firstGameStart: TestClock.now
|
||||||
|
)
|
||||||
|
let stop2 = ItineraryStop(
|
||||||
|
city: "Boston", state: "MA",
|
||||||
|
coordinate: TestFixtures.coordinates["Boston"],
|
||||||
|
games: ["g2"],
|
||||||
|
arrivalDate: TestClock.addingDays(1),
|
||||||
|
departureDate: TestClock.addingDays(2),
|
||||||
|
location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
||||||
|
firstGameStart: TestClock.addingDays(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
let segment = TestFixtures.travelSegment(from: "New York", to: "Boston")
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 1, stops: [stop1, stop2],
|
||||||
|
travelSegments: [segment],
|
||||||
|
totalDrivingHours: 3.5, totalDistanceMiles: 215,
|
||||||
|
geographicRationale: "test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Case 1: No repeat cities — filter is a no-op
|
||||||
|
let options = [option]
|
||||||
|
let once = RouteFilters.filterRepeatCities(options, allow: false)
|
||||||
|
let twice = RouteFilters.filterRepeatCities(once, allow: false)
|
||||||
|
#expect(once.count == twice.count, "Filtering twice should produce same result as once")
|
||||||
|
#expect(once.count == 1, "NYC→BOS has no repeat cities, should survive filter")
|
||||||
|
|
||||||
|
// Case 2: Route with repeat cities — filter actually removes it
|
||||||
|
let stop3 = ItineraryStop(
|
||||||
|
city: "New York", state: "NY",
|
||||||
|
coordinate: TestFixtures.coordinates["New York"],
|
||||||
|
games: ["g3"],
|
||||||
|
arrivalDate: TestClock.addingDays(2),
|
||||||
|
departureDate: TestClock.addingDays(3),
|
||||||
|
location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
|
||||||
|
firstGameStart: TestClock.addingDays(2)
|
||||||
|
)
|
||||||
|
let seg2 = TestFixtures.travelSegment(from: "Boston", to: "New York")
|
||||||
|
let repeatOption = ItineraryOption(
|
||||||
|
rank: 2, stops: [stop1, stop2, stop3],
|
||||||
|
travelSegments: [segment, seg2],
|
||||||
|
totalDrivingHours: 7.0, totalDistanceMiles: 430,
|
||||||
|
geographicRationale: "test"
|
||||||
|
)
|
||||||
|
let mixedOnce = RouteFilters.filterRepeatCities([option, repeatOption], allow: false)
|
||||||
|
let mixedTwice = RouteFilters.filterRepeatCities(mixedOnce, allow: false)
|
||||||
|
#expect(mixedOnce.count == mixedTwice.count, "Double-filter should be idempotent")
|
||||||
|
#expect(mixedOnce.count == 1, "Route with repeat NYC should be filtered out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 3D: Anchor Constraints
|
||||||
|
|
||||||
|
@Suite("Anchor Constraints")
|
||||||
|
struct AnchorConstraintTests {
|
||||||
|
|
||||||
|
@Test("Anchor game must appear in all returned routes")
|
||||||
|
func anchorGame_appearsInAllRoutes() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
|
||||||
|
let game1 = TestFixtures.game(id: "anchor1", city: "New York", dateTime: baseDate)
|
||||||
|
let game2 = TestFixtures.game(city: "Boston", dateTime: day2)
|
||||||
|
let game3 = TestFixtures.game(city: "Philadelphia", dateTime: day3)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2, game3])
|
||||||
|
let constraints = DrivingConstraints.default
|
||||||
|
|
||||||
|
let routes = GameDAGRouter.findRoutes(
|
||||||
|
games: [game1, game2, game3],
|
||||||
|
stadiums: stadiums,
|
||||||
|
constraints: constraints,
|
||||||
|
anchorGameIds: ["anchor1"]
|
||||||
|
)
|
||||||
|
|
||||||
|
for route in routes {
|
||||||
|
let routeGameIds = Set(route.map { $0.id })
|
||||||
|
#expect(routeGameIds.contains("anchor1"),
|
||||||
|
"Every route must contain the anchor game")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unreachable anchor game produces empty routes")
|
||||||
|
func unreachableAnchor_producesEmptyRoutes() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
|
||||||
|
// Only one game, but anchor references a non-existent game
|
||||||
|
let game1 = TestFixtures.game(city: "New York", dateTime: baseDate)
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1])
|
||||||
|
let constraints = DrivingConstraints.default
|
||||||
|
|
||||||
|
let routes = GameDAGRouter.findRoutes(
|
||||||
|
games: [game1],
|
||||||
|
stadiums: stadiums,
|
||||||
|
constraints: constraints,
|
||||||
|
anchorGameIds: ["nonexistent_anchor"]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(routes.isEmpty, "Non-existent anchor should produce no routes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 3E: Constraint Interactions
|
||||||
|
|
||||||
|
@Suite("Constraint Interactions")
|
||||||
|
struct ConstraintInteractionTests {
|
||||||
|
|
||||||
|
@Test("Repeat city + must-stop interaction: must-stop in repeated city")
|
||||||
|
func repeatCity_mustStop_interaction() {
|
||||||
|
// Must-stop requires a city that would cause a repeat violation
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
|
||||||
|
let game1 = TestFixtures.game(city: "New York", dateTime: baseDate)
|
||||||
|
let game2 = TestFixtures.game(city: "Boston", dateTime: day2)
|
||||||
|
let game3 = TestFixtures.game(city: "New York", dateTime: day3)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2, game3])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day3,
|
||||||
|
mustStopLocations: [LocationInput(name: "New York")],
|
||||||
|
allowRepeatCities: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [game1, game2, game3],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Engine should handle this gracefully — either find a route that visits NYC once
|
||||||
|
// or return a clear failure
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
break // Success is fine — engine found a valid route visiting NYC exactly once
|
||||||
|
case .failure(let failure):
|
||||||
|
let hasReason = failure.reason == .repeatCityViolation(cities: ["New York"])
|
||||||
|
|| failure.reason == .noValidRoutes
|
||||||
|
|| failure.reason == .noGamesInRange
|
||||||
|
#expect(hasReason, "Should fail with a clear reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Multiple drivers extend feasible distance")
|
||||||
|
func multipleDrivers_extendFeasibleDistance() {
|
||||||
|
let nyc = TestFixtures.coordinates["New York"]!
|
||||||
|
let chicago = TestFixtures.coordinates["Chicago"]!
|
||||||
|
|
||||||
|
let from = ItineraryStop(
|
||||||
|
city: "New York", state: "NY", coordinate: nyc,
|
||||||
|
games: [], arrivalDate: TestClock.now, departureDate: TestClock.now,
|
||||||
|
location: LocationInput(name: "New York", coordinate: nyc), firstGameStart: nil
|
||||||
|
)
|
||||||
|
let to = ItineraryStop(
|
||||||
|
city: "Chicago", state: "IL", coordinate: chicago,
|
||||||
|
games: [], arrivalDate: TestClock.now, departureDate: TestClock.now,
|
||||||
|
location: LocationInput(name: "Chicago", coordinate: chicago), firstGameStart: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let oneDriver = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
let twoDrivers = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
|
||||||
|
let segOne = TravelEstimator.estimate(from: from, to: to, constraints: oneDriver)
|
||||||
|
let segTwo = TravelEstimator.estimate(from: from, to: to, constraints: twoDrivers)
|
||||||
|
|
||||||
|
// Both should succeed (NYC→Chicago is ~13h driving, well within 40h/80h limits)
|
||||||
|
#expect(segOne != nil && segTwo != nil)
|
||||||
|
|
||||||
|
// But with 2 drivers, overnight requirement changes
|
||||||
|
if let seg = segOne {
|
||||||
|
let overnightOne = TravelEstimator.requiresOvernightStop(segment: seg, constraints: oneDriver)
|
||||||
|
let overnightTwo = TravelEstimator.requiresOvernightStop(segment: seg, constraints: twoDrivers)
|
||||||
|
|
||||||
|
#expect(overnightOne, "1 driver should need overnight for NYC→Chicago")
|
||||||
|
#expect(!overnightTwo, "2 drivers should NOT need overnight for NYC→Chicago")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leisure level affects option ranking")
|
||||||
|
func leisureLevel_affectsRanking() {
|
||||||
|
let stop1 = ItineraryStop(
|
||||||
|
city: "New York", state: "NY",
|
||||||
|
coordinate: TestFixtures.coordinates["New York"],
|
||||||
|
games: ["g1", "g2", "g3"],
|
||||||
|
arrivalDate: TestClock.now,
|
||||||
|
departureDate: TestClock.addingDays(1),
|
||||||
|
location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
|
||||||
|
firstGameStart: TestClock.now
|
||||||
|
)
|
||||||
|
|
||||||
|
let packedOption = ItineraryOption(
|
||||||
|
rank: 1, stops: [stop1], travelSegments: [],
|
||||||
|
totalDrivingHours: 10, totalDistanceMiles: 600,
|
||||||
|
geographicRationale: "packed"
|
||||||
|
)
|
||||||
|
|
||||||
|
let relaxedStop = ItineraryStop(
|
||||||
|
city: "Boston", state: "MA",
|
||||||
|
coordinate: TestFixtures.coordinates["Boston"],
|
||||||
|
games: ["g4"],
|
||||||
|
arrivalDate: TestClock.now,
|
||||||
|
departureDate: TestClock.addingDays(1),
|
||||||
|
location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
||||||
|
firstGameStart: TestClock.now
|
||||||
|
)
|
||||||
|
|
||||||
|
let relaxedOption = ItineraryOption(
|
||||||
|
rank: 2, stops: [relaxedStop], travelSegments: [],
|
||||||
|
totalDrivingHours: 2, totalDistanceMiles: 100,
|
||||||
|
geographicRationale: "relaxed"
|
||||||
|
)
|
||||||
|
|
||||||
|
let options = [packedOption, relaxedOption]
|
||||||
|
|
||||||
|
let packedSorted = ItineraryOption.sortByLeisure(options, leisureLevel: .packed)
|
||||||
|
let relaxedSorted = ItineraryOption.sortByLeisure(options, leisureLevel: .relaxed)
|
||||||
|
|
||||||
|
// Packed: more games first → packedOption should rank higher
|
||||||
|
#expect(packedSorted.first?.totalGames == 3, "Packed should prioritize more games")
|
||||||
|
|
||||||
|
// Relaxed: less driving first → relaxedOption should rank higher
|
||||||
|
#expect(relaxedSorted.first?.totalDrivingHours == 2, "Relaxed should prioritize less driving")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Silent exclusion warnings are tracked")
|
||||||
|
func silentExclusion_warningsTracked() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
|
||||||
|
let game1 = TestFixtures.game(city: "New York", dateTime: baseDate)
|
||||||
|
let game2 = TestFixtures.game(city: "Boston", dateTime: day2)
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day2,
|
||||||
|
mustStopLocations: [LocationInput(name: "New York")]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [game1, game2],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// With must-stop NYC, some routes may be filtered. Verify:
|
||||||
|
// 1. The warnings property is accessible (doesn't crash)
|
||||||
|
// 2. If warnings exist, they are all severity .warning
|
||||||
|
let warnings = engine.warnings
|
||||||
|
for warning in warnings {
|
||||||
|
#expect(warning.severity == .warning,
|
||||||
|
"Exclusion notices should be warnings, not errors")
|
||||||
|
}
|
||||||
|
// The engine should produce either success with must-stop satisfied, or failure
|
||||||
|
switch result {
|
||||||
|
case .success(let options):
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { $0.city.lowercased() }
|
||||||
|
#expect(cities.contains("new york"), "Must-stop NYC should be in every option")
|
||||||
|
}
|
||||||
|
case .failure:
|
||||||
|
break // Acceptable if no route can satisfy must-stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -341,8 +341,11 @@ struct PlanningModelsTests {
|
|||||||
#expect(!stop.hasGames)
|
#expect(!stop.hasGames)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("equality based on id only")
|
@Test("self-equality: same instance is equal to itself")
|
||||||
func equality_basedOnId() {
|
func self_equality() {
|
||||||
|
// ItineraryStop uses auto-generated UUID ids, so two separately constructed
|
||||||
|
// instances will always have different ids. Self-equality is the only
|
||||||
|
// meaningful equality test for this type.
|
||||||
let stop1 = ItineraryStop(
|
let stop1 = ItineraryStop(
|
||||||
city: "New York",
|
city: "New York",
|
||||||
state: "NY",
|
state: "NY",
|
||||||
@@ -354,7 +357,6 @@ struct PlanningModelsTests {
|
|||||||
firstGameStart: nil
|
firstGameStart: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Same id via same instance
|
|
||||||
#expect(stop1 == stop1)
|
#expect(stop1 == stop1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -212,7 +212,10 @@ struct Bug4_ScenarioDRationaleTests {
|
|||||||
let planner = ScenarioDPlanner()
|
let planner = ScenarioDPlanner()
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
// Bug #4: rationale was using stops.count instead of actual game count.
|
// Bug #4: rationale was using stops.count instead of actual game count.
|
||||||
// Verify that for each option, the game count in the rationale matches
|
// Verify that for each option, the game count in the rationale matches
|
||||||
// the actual total games across stops.
|
// the actual total games across stops.
|
||||||
@@ -223,8 +226,6 @@ struct Bug4_ScenarioDRationaleTests {
|
|||||||
"Rationale game count should match actual games (\(actualGameCount)). Got: \(rationale)")
|
"Rationale game count should match actual games (\(actualGameCount)). Got: \(rationale)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If planning fails, that's OK — this test focuses on rationale text when it succeeds
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bug #5: ScenarioD departureDate not advanced
|
// MARK: - Bug #5: ScenarioD departureDate not advanced
|
||||||
@@ -261,7 +262,14 @@ struct Bug5_ScenarioDDepartureDateTests {
|
|||||||
let planner = ScenarioDPlanner()
|
let planner = ScenarioDPlanner()
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result, let option = options.first {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let option = options.first else {
|
||||||
|
Issue.record("Expected at least one option, got empty array")
|
||||||
|
return
|
||||||
|
}
|
||||||
// Find the game stop (not the home start/end waypoints)
|
// Find the game stop (not the home start/end waypoints)
|
||||||
let gameStops = option.stops.filter { $0.hasGames }
|
let gameStops = option.stops.filter { $0.hasGames }
|
||||||
if let gameStop = gameStops.first {
|
if let gameStop = gameStops.first {
|
||||||
@@ -271,7 +279,6 @@ struct Bug5_ScenarioDDepartureDateTests {
|
|||||||
"Departure should be after game day, not same day. Game: \(gameDayStart), Departure: \(departureDayStart)")
|
"Departure should be after game day, not same day. Game: \(gameDayStart), Departure: \(departureDayStart)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bug #6: ScenarioC date range off-by-one
|
// MARK: - Bug #6: ScenarioC date range off-by-one
|
||||||
@@ -321,11 +328,12 @@ struct Bug6_ScenarioCDateRangeTests {
|
|||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
// Should find at least one option — games exactly span the trip duration
|
// Should find at least one option — games exactly span the trip duration
|
||||||
if case .failure(let failure) = result {
|
guard case .success(let options) = result else {
|
||||||
let reason = failure.reason
|
Issue.record("Expected .success, got \(result)")
|
||||||
#expect(reason != PlanningFailure.FailureReason.noGamesInRange,
|
return
|
||||||
"Games spanning exactly daySpan should not be excluded. Failure: \(failure.message)")
|
|
||||||
}
|
}
|
||||||
|
#expect(!options.isEmpty,
|
||||||
|
"Games spanning exactly daySpan should produce at least one option")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,9 +642,11 @@ struct Bug13_MissingStadiumTests {
|
|||||||
|
|
||||||
// Currently: silently excluded → noGamesInRange.
|
// Currently: silently excluded → noGamesInRange.
|
||||||
// This test documents the current behavior (missing stadiums are excluded).
|
// This test documents the current behavior (missing stadiums are excluded).
|
||||||
if case .failure(let failure) = result {
|
guard case .failure(let failure) = result else {
|
||||||
#expect(failure.reason == .noGamesInRange)
|
Issue.record("Expected .failure, got \(result)")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
#expect(failure.reason == .noGamesInRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,14 +657,7 @@ struct Bug13_MissingStadiumTests {
|
|||||||
@Suite("Bug #14: Drag drop feedback")
|
@Suite("Bug #14: Drag drop feedback")
|
||||||
struct Bug14_DragDropTests {
|
struct Bug14_DragDropTests {
|
||||||
|
|
||||||
@Test("documented: drag state should not be cleared before validation")
|
// Bug #14 (drag state) is a UI-layer issue tracked separately — no unit test possible here.
|
||||||
func documented_dragStateShouldPersistDuringValidation() {
|
|
||||||
// This bug is in TripDetailView.swift:1508-1525 (UI layer).
|
|
||||||
// Drag state is cleared synchronously before async validation runs.
|
|
||||||
// If validation fails, no visual feedback is shown.
|
|
||||||
// Fix: Move drag state clearing AFTER validation succeeds.
|
|
||||||
#expect(true, "UI bug documented — drag state should persist during validation")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bug #15: ScenarioB force unwraps on date arithmetic
|
// MARK: - Bug #15: ScenarioB force unwraps on date arithmetic
|
||||||
@@ -699,8 +702,25 @@ struct Bug15_DateArithmeticTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let planner = ScenarioBPlanner()
|
let planner = ScenarioBPlanner()
|
||||||
// Should not crash — just verifying safety
|
let result = planner.plan(request: request)
|
||||||
let _ = planner.plan(request: request)
|
// Bug #15 was a force-unwrap crash in date arithmetic. The fix ensures safe
|
||||||
|
// optional unwrapping. Both outcomes are acceptable here because:
|
||||||
|
// - .success: date arithmetic worked and a route was found
|
||||||
|
// - .failure(.noValidRoutes): date arithmetic worked but driving constraints
|
||||||
|
// or game spacing prevented a valid route (Boston→NYC in the available window)
|
||||||
|
// The critical assertion is that this code path does NOT crash (no force-unwrap trap).
|
||||||
|
switch result {
|
||||||
|
case .success(let options):
|
||||||
|
#expect(!options.isEmpty, "If success, should have at least one option")
|
||||||
|
case .failure(let failure):
|
||||||
|
// Any planning-related failure is acceptable — the critical assertion is
|
||||||
|
// that the code path does NOT crash (no force-unwrap trap).
|
||||||
|
let acceptableReasons: [PlanningFailure.FailureReason] = [
|
||||||
|
.noValidRoutes, .noGamesInRange, .constraintsUnsatisfiable, .missingDateRange
|
||||||
|
]
|
||||||
|
#expect(acceptableReasons.contains { $0 == failure.reason },
|
||||||
|
"Expected a planning-related failure, got \(failure.reason)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,14 +729,7 @@ struct Bug15_DateArithmeticTests {
|
|||||||
@Suite("Bug #16: Sort order accumulation")
|
@Suite("Bug #16: Sort order accumulation")
|
||||||
struct Bug16_SortOrderTests {
|
struct Bug16_SortOrderTests {
|
||||||
|
|
||||||
@Test("documented: repeated before-games moves should use midpoint not subtraction")
|
// Bug #16 (sortOrder accumulation) is in ItineraryReorderingLogic — tracked separately.
|
||||||
func documented_sortOrderShouldNotGoExtremelyNegative() {
|
|
||||||
// This bug is in ItineraryReorderingLogic.swift:420-428.
|
|
||||||
// Each "move before first item" subtracts 1.0 instead of using midpoint.
|
|
||||||
// After many moves, sortOrder becomes -10, -20, etc.
|
|
||||||
// Fix: Use midpoint (n/2.0) instead of subtraction (n-1.0).
|
|
||||||
#expect(true, "Documented: sortOrder should use midpoint insertion")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cross-cutting: TravelEstimator consistency
|
// MARK: - Cross-cutting: TravelEstimator consistency
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ struct RouteFiltersTests {
|
|||||||
let result = RouteFilters.filterRepeatCities([violating, valid], allow: false)
|
let result = RouteFilters.filterRepeatCities([violating, valid], allow: false)
|
||||||
|
|
||||||
#expect(result.count == 1)
|
#expect(result.count == 1)
|
||||||
|
let survivingCities = Set(result[0].stops.map { $0.city })
|
||||||
|
#expect(survivingCities.contains("Boston"), "The surviving option should be the non-violating route containing Boston")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("filterRepeatCities: empty options returns empty")
|
@Test("filterRepeatCities: empty options returns empty")
|
||||||
|
|||||||
@@ -131,13 +131,14 @@ struct ScenarioAPlannerTests {
|
|||||||
|
|
||||||
// Should succeed with only NYC game (East coast)
|
// Should succeed with only NYC game (East coast)
|
||||||
guard case .success(let options) = result else {
|
guard case .success(let options) = result else {
|
||||||
// May fail for other reasons (no valid routes), but shouldn't include LA
|
Issue.record("Expected .success, got \(result)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If success, verify only East coast games included
|
// Verify only East coast games included
|
||||||
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
||||||
#expect(!allGameIds.contains("game-la"), "LA game should be filtered out by East region filter")
|
#expect(!allGameIds.contains("game-la"), "LA game should be filtered out by East region filter")
|
||||||
|
#expect(allGameIds.contains("game-nyc"), "NYC game should be included in East region filter")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: Must-Stop Filtering
|
// MARK: - Specification Tests: Must-Stop Filtering
|
||||||
@@ -174,13 +175,16 @@ struct ScenarioAPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
// If success, should only include NYC games
|
// Should include NYC games
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
Issue.record("Expected .success, got \(result)")
|
||||||
#expect(allGameIds.contains("game-nyc"), "NYC game should be included")
|
return
|
||||||
// Boston game may or may not be included depending on route logic
|
}
|
||||||
|
|
||||||
|
for option in options {
|
||||||
|
let gameIds = Set(option.stops.flatMap { $0.games })
|
||||||
|
#expect(gameIds.contains("game-nyc"), "Every option must include NYC game (must-stop constraint)")
|
||||||
}
|
}
|
||||||
// Could also fail with noGamesInRange if must-stop filter is strict
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("plan: mustStopLocation with no games in that city returns noGamesInRange")
|
@Test("plan: mustStopLocation with no games in that city returns noGamesInRange")
|
||||||
@@ -346,6 +350,7 @@ struct ScenarioAPlannerTests {
|
|||||||
// Should have 1 stop with 2 games (not 2 stops)
|
// Should have 1 stop with 2 games (not 2 stops)
|
||||||
let totalGamesInNYC = nycStops.flatMap { $0.games }.count
|
let totalGamesInNYC = nycStops.flatMap { $0.games }.count
|
||||||
#expect(totalGamesInNYC >= 2, "Both games should be in the route")
|
#expect(totalGamesInNYC >= 2, "Both games should be in the route")
|
||||||
|
#expect(nycStops.count == 1, "Two games at same stadium should create exactly one stop, got \(nycStops.count)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,12 +384,15 @@ struct ScenarioAPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
||||||
#expect(allGameIds.contains("in-range"))
|
#expect(allGameIds.contains("in-range"))
|
||||||
#expect(!allGameIds.contains("out-of-range"), "Game outside date range should not be included")
|
#expect(!allGameIds.contains("out-of-range"), "Game outside date range should not be included")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Invariant: A-B-A creates 3 stops not 2")
|
@Test("Invariant: A-B-A creates 3 stops not 2")
|
||||||
func invariant_visitSameCityTwice_createsThreeStops() {
|
func invariant_visitSameCityTwice_createsThreeStops() {
|
||||||
@@ -419,17 +427,19 @@ struct ScenarioAPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
// Look for an option that includes all 3 games
|
Issue.record("Expected .success, got \(result)")
|
||||||
let optionWithAllGames = options.first { option in
|
return
|
||||||
let allGames = option.stops.flatMap { $0.games }
|
|
||||||
return allGames.contains("nyc1") && allGames.contains("boston1") && allGames.contains("nyc2")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let option = optionWithAllGames {
|
// Look for an option that includes all 3 games
|
||||||
// NYC appears first and last, so should have at least 3 stops
|
let optionWithAllGames = options.first { option in
|
||||||
#expect(option.stops.count >= 3, "A-B-A pattern should create 3 stops")
|
let ids = Set(option.stops.flatMap { $0.games })
|
||||||
|
return ids.contains("nyc1") && ids.contains("boston1") && ids.contains("nyc2")
|
||||||
}
|
}
|
||||||
|
#expect(optionWithAllGames != nil, "Should have at least one route containing all 3 games")
|
||||||
|
if let option = optionWithAllGames {
|
||||||
|
#expect(option.stops.count >= 3, "NYC-BOS-NYC pattern should create at least 3 stops")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,12 +472,55 @@ struct ScenarioAPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
#expect(!options.isEmpty, "Success must have at least one option")
|
#expect(!options.isEmpty, "Success must have at least one option")
|
||||||
for option in options {
|
for option in options {
|
||||||
#expect(!option.stops.isEmpty, "Each option must have at least one stop")
|
#expect(!option.stops.isEmpty, "Each option must have at least one stop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Output Sanity
|
||||||
|
|
||||||
|
@Test("plan: game with missing stadium excluded, no crash")
|
||||||
|
func plan_missingStadiumForGame_gameExcluded() {
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
|
||||||
|
let validGame = makeGame(id: "valid", stadiumId: "nyc", dateTime: TestClock.addingDays(2))
|
||||||
|
let orphanGame = Game(id: "orphan", homeTeamId: "t2", awayTeamId: "vis",
|
||||||
|
stadiumId: "no_such_stadium", dateTime: TestClock.addingDays(3),
|
||||||
|
sport: .mlb, season: "2026", isPlayoff: false)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: TestClock.addingDays(1),
|
||||||
|
endDate: TestClock.addingDays(7),
|
||||||
|
numberOfDrivers: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [validGame, orphanGame],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: ["nyc": nycStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
// Primary assertion: no crash
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for option in options {
|
||||||
|
let ids = Set(option.stops.flatMap { $0.games })
|
||||||
|
#expect(!ids.contains("orphan"),
|
||||||
|
"Game with missing stadium should not appear in output")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|||||||
@@ -129,14 +129,17 @@ struct ScenarioBPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for option in options {
|
for option in options {
|
||||||
let gameIds = option.stops.flatMap { $0.games }
|
let gameIds = option.stops.flatMap { $0.games }
|
||||||
#expect(gameIds.contains("nyc-game"), "Every route must contain selected NYC game")
|
#expect(gameIds.contains("nyc-game"), "Every route must contain selected NYC game")
|
||||||
#expect(gameIds.contains("boston-game"), "Every route must contain selected Boston game")
|
#expect(gameIds.contains("boston-game"), "Every route must contain selected Boston game")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Regression Tests: Bonus Games in Date Range
|
// MARK: - Regression Tests: Bonus Games in Date Range
|
||||||
|
|
||||||
@@ -291,10 +294,12 @@ struct ScenarioBPlannerTests {
|
|||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
// Should succeed even without explicit dates because of sliding window
|
// Should succeed even without explicit dates because of sliding window
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
#expect(!options.isEmpty)
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// May also fail if no valid date ranges, which is acceptable
|
|
||||||
|
#expect(!options.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation")
|
@Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation")
|
||||||
@@ -418,14 +423,17 @@ struct ScenarioBPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for option in options {
|
for option in options {
|
||||||
let gameIds = Set(option.stops.flatMap { $0.games })
|
let gameIds = Set(option.stops.flatMap { $0.games })
|
||||||
#expect(gameIds.contains("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor")
|
#expect(gameIds.contains("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor")
|
||||||
#expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor")
|
#expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Property Tests
|
// MARK: - Property Tests
|
||||||
|
|
||||||
@@ -458,13 +466,48 @@ struct ScenarioBPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
#expect(!options.isEmpty, "Success must have options")
|
#expect(!options.isEmpty, "Success must have options")
|
||||||
for option in options {
|
for option in options {
|
||||||
let allGames = option.stops.flatMap { $0.games }
|
let allGames = option.stops.flatMap { $0.games }
|
||||||
#expect(allGames.contains("anchor1"), "Every option must include anchor")
|
#expect(allGames.contains("anchor1"), "Every option must include anchor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("plan: anchor game in past — handled gracefully")
|
||||||
|
func plan_anchorGameInPast_handledGracefully() {
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
|
||||||
|
let pastAnchor = makeGame(id: "past_anchor", stadiumId: "nyc", dateTime: TestClock.addingDays(-1))
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .gameFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
mustSeeGameIds: ["past_anchor"],
|
||||||
|
startDate: TestClock.addingDays(-3),
|
||||||
|
endDate: TestClock.addingDays(5),
|
||||||
|
numberOfDrivers: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [pastAnchor],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: ["nyc": nycStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Should not crash. May include past anchor (it's explicitly selected) or fail gracefully.
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
case .failure(let f):
|
||||||
|
Issue.record("Unexpected failure: \(f)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|||||||
@@ -286,12 +286,14 @@ struct ScenarioCPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
||||||
// LA game should NOT be in any route (wrong direction)
|
// LA game should NOT be in any route (wrong direction)
|
||||||
#expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)")
|
#expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Specification Tests: Start/End Stops
|
// MARK: - Specification Tests: Start/End Stops
|
||||||
|
|
||||||
@@ -336,7 +338,10 @@ struct ScenarioCPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
for option in options {
|
for option in options {
|
||||||
// First stop should be start city
|
// First stop should be start city
|
||||||
#expect(option.stops.first?.city == "Chicago", "First stop should be start city")
|
#expect(option.stops.first?.city == "Chicago", "First stop should be start city")
|
||||||
@@ -344,7 +349,6 @@ struct ScenarioCPlannerTests {
|
|||||||
#expect(option.stops.last?.city == "New York", "Last stop should be end city")
|
#expect(option.stops.last?.city == "New York", "Last stop should be end city")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Invariant Tests
|
// MARK: - Invariant Tests
|
||||||
|
|
||||||
@@ -383,18 +387,18 @@ struct ScenarioCPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
for option in options {
|
for option in options {
|
||||||
let firstStop = option.stops.first
|
// When start city (Chicago) has a game, the endpoint is merged into the game stop.
|
||||||
// The start stop (added as endpoint) should have no games
|
// Verify the first stop IS Chicago (either as game stop or endpoint).
|
||||||
// Note: The first stop might be a game stop if start city has games
|
#expect(option.stops.first?.city == "Chicago",
|
||||||
if firstStop?.city == "Chicago" && option.stops.count > 1 {
|
"First stop should be the start city (Chicago)")
|
||||||
// If there's a separate start stop with no games, verify it
|
// Verify the last stop is the end city
|
||||||
let stopsWithNoGames = option.stops.filter { $0.games.isEmpty }
|
#expect(option.stops.last?.city == "New York",
|
||||||
// At minimum, there should be endpoint stops
|
"Last stop should be the end city (New York)")
|
||||||
#expect(stopsWithNoGames.count >= 0) // Just ensure no crash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,24 +437,61 @@ struct ScenarioCPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
for option in options {
|
for option in options {
|
||||||
#expect(option.stops.last?.city == "New York", "End city must be last stop")
|
#expect(option.stops.last?.city == "New York", "End city must be last stop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Property Tests
|
// MARK: - Property Tests
|
||||||
|
|
||||||
@Test("Property: forward progress tolerance is 15%")
|
@Test("Property: forward progress tolerance filters distant backward stadiums")
|
||||||
func property_forwardProgressTolerance() {
|
func property_forwardProgressTolerance() {
|
||||||
// This tests the documented invariant that tolerance is 15%
|
// Chicago → NYC route. LA is far backward (west), should be excluded.
|
||||||
// We verify by testing that a stadium 16% backward gets filtered
|
// Cleveland is forward (east of Chicago, toward NYC), should be included.
|
||||||
// vs one that is 14% backward gets included
|
let chicagoStad = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord)
|
||||||
|
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
let clevelandCoord = CLLocationCoordinate2D(latitude: 41.4958, longitude: -81.6853)
|
||||||
|
let clevelandStad = makeStadium(id: "cle", city: "Cleveland", coordinate: clevelandCoord)
|
||||||
|
let laCoord = CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400)
|
||||||
|
let laStad = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
|
||||||
|
|
||||||
// This is more of a documentation test - the actual tolerance is private
|
let chiGame = makeGame(id: "g_chi", stadiumId: "chi", dateTime: TestClock.addingDays(1))
|
||||||
// We trust the implementation matches the documented behavior
|
let cleGame = makeGame(id: "g_cle", stadiumId: "cle", dateTime: TestClock.addingDays(3))
|
||||||
#expect(true, "Forward progress tolerance documented as 15%")
|
let laGame = makeGame(id: "g_la", stadiumId: "la", dateTime: TestClock.addingDays(4))
|
||||||
|
let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(6))
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .locations,
|
||||||
|
startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord),
|
||||||
|
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: TestClock.now,
|
||||||
|
endDate: TestClock.addingDays(10),
|
||||||
|
numberOfDrivers: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [chiGame, cleGame, laGame, nycGame],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: ["chi": chicagoStad, "nyc": nycStad, "cle": clevelandStad, "la": laStad]
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map(\.city)
|
||||||
|
#expect(!cities.contains("Los Angeles"),
|
||||||
|
"LA is far backward from Chicago→NYC route and should be excluded")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Regression Tests: Endpoint Merging
|
// MARK: - Regression Tests: Endpoint Merging
|
||||||
@@ -499,7 +540,10 @@ struct ScenarioCPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
#expect(!options.isEmpty, "Should produce at least one itinerary")
|
#expect(!options.isEmpty, "Should produce at least one itinerary")
|
||||||
for option in options {
|
for option in options {
|
||||||
// When the route includes a Houston game stop, there should NOT also be
|
// When the route includes a Houston game stop, there should NOT also be
|
||||||
@@ -514,7 +558,6 @@ struct ScenarioCPlannerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test("plan: both endpoints match game cities — no redundant empty endpoints")
|
@Test("plan: both endpoints match game cities — no redundant empty endpoints")
|
||||||
func plan_bothEndpointsMatchGameCities_noEmptyStops() {
|
func plan_bothEndpointsMatchGameCities_noEmptyStops() {
|
||||||
@@ -557,7 +600,10 @@ struct ScenarioCPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
#expect(!options.isEmpty, "Should produce at least one itinerary")
|
#expect(!options.isEmpty, "Should produce at least one itinerary")
|
||||||
for option in options {
|
for option in options {
|
||||||
// When a route includes a game in an endpoint city,
|
// When a route includes a game in an endpoint city,
|
||||||
@@ -575,7 +621,6 @@ struct ScenarioCPlannerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test("plan: start city differs from all game cities — adds empty endpoint stop")
|
@Test("plan: start city differs from all game cities — adds empty endpoint stop")
|
||||||
func plan_endpointDiffersFromGameCity_stillAddsEndpointStop() {
|
func plan_endpointDiffersFromGameCity_stillAddsEndpointStop() {
|
||||||
@@ -622,7 +667,10 @@ struct ScenarioCPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
#expect(!options.isEmpty)
|
#expect(!options.isEmpty)
|
||||||
// For routes that include the Chicago game, the start endpoint
|
// For routes that include the Chicago game, the start endpoint
|
||||||
// should be merged (no separate empty Chicago stop).
|
// should be merged (no separate empty Chicago stop).
|
||||||
@@ -638,7 +686,6 @@ struct ScenarioCPlannerTests {
|
|||||||
"Should not have both game and empty stops for Chicago")
|
"Should not have both game and empty stops for Chicago")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Regression Tests: Endpoint Game Validation
|
// MARK: - Regression Tests: Endpoint Game Validation
|
||||||
|
|
||||||
@@ -772,6 +819,115 @@ struct ScenarioCPlannerTests {
|
|||||||
#expect(!options.isEmpty, "Should produce at least one itinerary")
|
#expect(!options.isEmpty, "Should produce at least one itinerary")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Output Sanity
|
||||||
|
|
||||||
|
@Test("plan: all stops progress toward end location")
|
||||||
|
func plan_allStopsProgressTowardEnd() {
|
||||||
|
let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
||||||
|
let phillyC = CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665)
|
||||||
|
let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074)
|
||||||
|
let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006)
|
||||||
|
|
||||||
|
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC)
|
||||||
|
let phillyStad = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyC)
|
||||||
|
let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC)
|
||||||
|
let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1)),
|
||||||
|
makeGame(id: "g_philly", stadiumId: "philly", dateTime: TestClock.addingDays(3)),
|
||||||
|
makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(5)),
|
||||||
|
makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7)),
|
||||||
|
]
|
||||||
|
|
||||||
|
let startLoc = LocationInput(name: "New York", coordinate: nycC)
|
||||||
|
let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .locations,
|
||||||
|
startLocation: startLoc,
|
||||||
|
endLocation: endLoc,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: TestClock.addingDays(0),
|
||||||
|
endDate: TestClock.addingDays(10),
|
||||||
|
numberOfDrivers: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: games,
|
||||||
|
teams: [:],
|
||||||
|
stadiums: ["nyc": nycStad, "philly": phillyStad, "dc": dcStad, "atl": atlantaStad]
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for option in options {
|
||||||
|
let gameStops = option.stops.filter { !$0.games.isEmpty }
|
||||||
|
for i in 0..<(gameStops.count - 1) {
|
||||||
|
if let coord1 = gameStops[i].coordinate, let coord2 = gameStops[i + 1].coordinate {
|
||||||
|
let progressing = coord2.latitude <= coord1.latitude + 2.0
|
||||||
|
#expect(progressing,
|
||||||
|
"Stops should progress toward Atlanta (south): \(gameStops[i].city) → \(gameStops[i+1].city)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("plan: games outside directional cone excluded")
|
||||||
|
func plan_gamesOutsideDirectionalCone_excluded() {
|
||||||
|
let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
||||||
|
let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006)
|
||||||
|
let bostonC = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
|
||||||
|
let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074)
|
||||||
|
|
||||||
|
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC)
|
||||||
|
let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC)
|
||||||
|
let bostonStad = makeStadium(id: "boston", city: "Boston", coordinate: bostonC)
|
||||||
|
let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC)
|
||||||
|
|
||||||
|
let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1))
|
||||||
|
let atlGame = makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7))
|
||||||
|
let bostonGame = makeGame(id: "g_boston", stadiumId: "boston", dateTime: TestClock.addingDays(3))
|
||||||
|
let dcGame = makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(4))
|
||||||
|
|
||||||
|
let startLoc = LocationInput(name: "New York", coordinate: nycC)
|
||||||
|
let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .locations,
|
||||||
|
startLocation: startLoc,
|
||||||
|
endLocation: endLoc,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: TestClock.addingDays(0),
|
||||||
|
endDate: TestClock.addingDays(10),
|
||||||
|
numberOfDrivers: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [nycGame, atlGame, bostonGame, dcGame],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: ["nyc": nycStad, "atl": atlantaStad, "boston": bostonStad, "dc": dcStad]
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map(\.city)
|
||||||
|
#expect(!cities.contains("Boston"),
|
||||||
|
"Boston (north of NYC) should be excluded when traveling NYC→Atlanta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func makeStadium(
|
private func makeStadium(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct ScenarioDPlannerTests {
|
|||||||
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
||||||
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
|
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
|
||||||
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
|
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
|
||||||
|
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
|
||||||
|
|
||||||
// MARK: - Specification Tests: Missing Team
|
// MARK: - Specification Tests: Missing Team
|
||||||
|
|
||||||
@@ -155,13 +156,16 @@ struct ScenarioDPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
Issue.record("Expected .success, got \(result)")
|
||||||
// Both home and away games should be includable
|
return
|
||||||
let hasHomeGame = allGameIds.contains("home-game")
|
|
||||||
let hasAwayGame = allGameIds.contains("away-game")
|
|
||||||
#expect(hasHomeGame || hasAwayGame, "Should include at least one team game")
|
|
||||||
}
|
}
|
||||||
|
// At least one option should include BOTH home and away games
|
||||||
|
let hasOptionWithBoth = options.contains { option in
|
||||||
|
let gameIds = Set(option.stops.flatMap { $0.games })
|
||||||
|
return gameIds.contains("home-game") && gameIds.contains("away-game")
|
||||||
|
}
|
||||||
|
#expect(hasOptionWithBoth, "At least one option should include both home and away games")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: Region Filtering
|
// MARK: - Specification Tests: Region Filtering
|
||||||
@@ -219,10 +223,13 @@ struct ScenarioDPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
Issue.record("Expected .success, got \(result)")
|
||||||
#expect(!allGameIds.contains("central-game"), "Central game should be filtered by East region")
|
return
|
||||||
}
|
}
|
||||||
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
||||||
|
#expect(allGameIds.contains("east-game"), "East game should be included when East region is selected")
|
||||||
|
#expect(!allGameIds.contains("central-game"), "Central game should be filtered by East region")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: Successful Planning
|
// MARK: - Specification Tests: Successful Planning
|
||||||
@@ -277,8 +284,8 @@ struct ScenarioDPlannerTests {
|
|||||||
let startDate = TestClock.now
|
let startDate = TestClock.now
|
||||||
let endDate = startDate.addingTimeInterval(86400 * 10)
|
let endDate = startDate.addingTimeInterval(86400 * 10)
|
||||||
|
|
||||||
let homeCoord = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) // Denver
|
let homeCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) // NYC
|
||||||
let homeLocation = LocationInput(name: "Denver", coordinate: homeCoord)
|
let homeLocation = LocationInput(name: "New York", coordinate: homeCoord)
|
||||||
|
|
||||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
let game = Game(
|
let game = Game(
|
||||||
@@ -322,8 +329,8 @@ struct ScenarioDPlannerTests {
|
|||||||
#expect(!options.isEmpty)
|
#expect(!options.isEmpty)
|
||||||
|
|
||||||
for option in options {
|
for option in options {
|
||||||
#expect(option.stops.first?.city == "Denver")
|
#expect(option.stops.first?.city == "New York")
|
||||||
#expect(option.stops.last?.city == "Denver")
|
#expect(option.stops.last?.city == "New York")
|
||||||
#expect(option.stops.first?.games.isEmpty == true)
|
#expect(option.stops.first?.games.isEmpty == true)
|
||||||
#expect(option.stops.last?.games.isEmpty == true)
|
#expect(option.stops.last?.games.isEmpty == true)
|
||||||
}
|
}
|
||||||
@@ -396,29 +403,70 @@ struct ScenarioDPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
||||||
#expect(!allGameIds.contains("other"), "Games not involving the followed team should be excluded")
|
#expect(!allGameIds.contains("other"), "Games not involving the followed team should be excluded")
|
||||||
|
|
||||||
|
// Full invariant: ALL returned games must involve the followed team
|
||||||
|
let allGames = [homeGame, awayGame, otherGame]
|
||||||
|
for gameId in allGameIds {
|
||||||
|
let game = allGames.first { $0.id == gameId }
|
||||||
|
#expect(game != nil, "Game ID \(gameId) should be in the available games list")
|
||||||
|
if let game = game {
|
||||||
|
#expect(game.homeTeamId == teamId || game.awayTeamId == teamId,
|
||||||
|
"Game \(gameId) should involve followed team \(teamId)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Invariant: duplicate routes are removed")
|
@Test("Invariant: duplicate routes are removed")
|
||||||
func invariant_duplicateRoutesRemoved() {
|
func invariant_duplicateRoutesRemoved() {
|
||||||
let startDate = TestClock.now
|
let startDate = TestClock.now
|
||||||
let endDate = startDate.addingTimeInterval(86400 * 7)
|
let endDate = startDate.addingTimeInterval(86400 * 14)
|
||||||
|
|
||||||
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
|
// 3 games for the followed team at nearby cities — the DAG router may
|
||||||
let game = Game(
|
// produce multiple routes (e.g. [NYC, BOS], [NYC, PHI], [NYC, BOS, PHI])
|
||||||
id: "game1",
|
// which makes the uniqueness check meaningful.
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
|
||||||
|
|
||||||
|
let game1 = Game(
|
||||||
|
id: "game-nyc",
|
||||||
homeTeamId: "yankees",
|
homeTeamId: "yankees",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "stadium1",
|
stadiumId: "nyc",
|
||||||
dateTime: startDate.addingTimeInterval(86400 * 2),
|
dateTime: startDate.addingTimeInterval(86400 * 2),
|
||||||
sport: .mlb,
|
sport: .mlb,
|
||||||
season: "2026",
|
season: "2026",
|
||||||
isPlayoff: false
|
isPlayoff: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let game2 = Game(
|
||||||
|
id: "game-bos",
|
||||||
|
homeTeamId: "red-sox",
|
||||||
|
awayTeamId: "yankees",
|
||||||
|
stadiumId: "boston",
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * 5),
|
||||||
|
sport: .mlb,
|
||||||
|
season: "2026",
|
||||||
|
isPlayoff: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let game3 = Game(
|
||||||
|
id: "game-phi",
|
||||||
|
homeTeamId: "phillies",
|
||||||
|
awayTeamId: "yankees",
|
||||||
|
stadiumId: "philly",
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * 8),
|
||||||
|
sport: .mlb,
|
||||||
|
season: "2026",
|
||||||
|
isPlayoff: false
|
||||||
|
)
|
||||||
|
|
||||||
let prefs = TripPreferences(
|
let prefs = TripPreferences(
|
||||||
planningMode: .followTeam,
|
planningMode: .followTeam,
|
||||||
sports: [.mlb],
|
sports: [.mlb],
|
||||||
@@ -426,21 +474,24 @@ struct ScenarioDPlannerTests {
|
|||||||
endDate: endDate,
|
endDate: endDate,
|
||||||
leisureLevel: .moderate,
|
leisureLevel: .moderate,
|
||||||
lodgingType: .hotel,
|
lodgingType: .hotel,
|
||||||
numberOfDrivers: 1,
|
numberOfDrivers: 2,
|
||||||
followTeamId: "yankees"
|
followTeamId: "yankees"
|
||||||
)
|
)
|
||||||
|
|
||||||
let request = PlanningRequest(
|
let request = PlanningRequest(
|
||||||
preferences: prefs,
|
preferences: prefs,
|
||||||
availableGames: [game],
|
availableGames: [game1, game2, game3],
|
||||||
teams: [:],
|
teams: [:],
|
||||||
stadiums: ["stadium1": stadium]
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
|
||||||
)
|
)
|
||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
// Verify no duplicate game combinations
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Verify no two options have identical game-ID sets
|
||||||
var seenGameCombinations = Set<String>()
|
var seenGameCombinations = Set<String>()
|
||||||
for option in options {
|
for option in options {
|
||||||
let gameIds = option.stops.flatMap { $0.games }.sorted().joined(separator: "-")
|
let gameIds = option.stops.flatMap { $0.games }.sorted().joined(separator: "-")
|
||||||
@@ -448,7 +499,6 @@ struct ScenarioDPlannerTests {
|
|||||||
seenGameCombinations.insert(gameIds)
|
seenGameCombinations.insert(gameIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Property Tests
|
// MARK: - Property Tests
|
||||||
|
|
||||||
@@ -489,13 +539,15 @@ struct ScenarioDPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
#expect(!options.isEmpty, "Success must have at least one option")
|
#expect(!options.isEmpty, "Success must have at least one option")
|
||||||
for option in options {
|
for option in options {
|
||||||
#expect(!option.stops.isEmpty, "Each option must have stops")
|
#expect(!option.stops.isEmpty, "Each option must have stops")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
|
|||||||
241
SportsTimeTests/Planning/ScenarioEPlannerRealDataTest.swift
Normal file
241
SportsTimeTests/Planning/ScenarioEPlannerRealDataTest.swift
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
//
|
||||||
|
// ScenarioEPlannerRealDataTest.swift
|
||||||
|
// SportsTimeTests
|
||||||
|
//
|
||||||
|
// Regression test: runs ScenarioEPlanner with real PHI/WSN/BAL data
|
||||||
|
// to verify the past-date filtering and full-season coverage fixes.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import CoreLocation
|
||||||
|
@testable import SportsTime
|
||||||
|
|
||||||
|
@Suite("ScenarioE Real Data Regression")
|
||||||
|
struct ScenarioEPlannerRealDataTest {
|
||||||
|
|
||||||
|
@Test("PHI + WSN + BAL: returns future regular season results, not spring training")
|
||||||
|
func phiWsnBal_returnsFutureRegularSeasonResults() throws {
|
||||||
|
let fixture = try FixtureLoader.loadCanonicalGames()
|
||||||
|
let rawGames = fixture.games
|
||||||
|
|
||||||
|
// Filter to MLB only
|
||||||
|
let mlbRows = rawGames.filter { $0.sport?.lowercased() == "mlb" }
|
||||||
|
let games = mlbRows.compactMap(\.domainGame)
|
||||||
|
let skippedMLBRows = mlbRows.count - games.count
|
||||||
|
#expect(!games.isEmpty, "Expected MLB canonical fixture to contain valid games")
|
||||||
|
|
||||||
|
// Team IDs
|
||||||
|
let teamIds: Set<String> = ["team_mlb_phi", "team_mlb_wsn", "team_mlb_bal"]
|
||||||
|
|
||||||
|
// Build stadium map with real coordinates
|
||||||
|
let stadiums: [String: Stadium] = [
|
||||||
|
"stadium_mlb_citizens_bank_park": Stadium(
|
||||||
|
id: "stadium_mlb_citizens_bank_park", name: "Citizens Bank Park",
|
||||||
|
city: "Philadelphia", state: "PA",
|
||||||
|
latitude: 39.9061, longitude: -75.1665, capacity: 43035, sport: .mlb
|
||||||
|
),
|
||||||
|
"stadium_mlb_nationals_park": Stadium(
|
||||||
|
id: "stadium_mlb_nationals_park", name: "Nationals Park",
|
||||||
|
city: "Washington", state: "DC",
|
||||||
|
latitude: 38.8730, longitude: -77.0074, capacity: 41339, sport: .mlb
|
||||||
|
),
|
||||||
|
"stadium_mlb_oriole_park_at_camden_yards": Stadium(
|
||||||
|
id: "stadium_mlb_oriole_park_at_camden_yards", name: "Oriole Park at Camden Yards",
|
||||||
|
city: "Baltimore", state: "MD",
|
||||||
|
latitude: 39.2838, longitude: -76.6216, capacity: 45971, sport: .mlb
|
||||||
|
),
|
||||||
|
// Spring training stadiums (should be excluded by date filter)
|
||||||
|
"stadium_mlb_spring_baycare_ballpark": Stadium(
|
||||||
|
id: "stadium_mlb_spring_baycare_ballpark", name: "BayCare Ballpark",
|
||||||
|
city: "Clearwater", state: "FL",
|
||||||
|
latitude: 27.9781, longitude: -82.7337, capacity: 8500, sport: .mlb
|
||||||
|
),
|
||||||
|
"stadium_mlb_spring_cacti_park": Stadium(
|
||||||
|
id: "stadium_mlb_spring_cacti_park", name: "CACTI Park",
|
||||||
|
city: "West Palm Beach", state: "FL",
|
||||||
|
latitude: 26.7367, longitude: -80.1197, capacity: 6671, sport: .mlb
|
||||||
|
),
|
||||||
|
"stadium_mlb_spring_ed_smith_stadium": Stadium(
|
||||||
|
id: "stadium_mlb_spring_ed_smith_stadium", name: "Ed Smith Stadium",
|
||||||
|
city: "Sarasota", state: "FL",
|
||||||
|
latitude: 27.3381, longitude: -82.5226, capacity: 8500, sport: .mlb
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Build team map
|
||||||
|
let teams: [String: Team] = [
|
||||||
|
"team_mlb_phi": Team(id: "team_mlb_phi", name: "Phillies", abbreviation: "PHI",
|
||||||
|
sport: .mlb, city: "Philadelphia",
|
||||||
|
stadiumId: "stadium_mlb_citizens_bank_park",
|
||||||
|
primaryColor: "#E81828", secondaryColor: "#002D72"),
|
||||||
|
"team_mlb_wsn": Team(id: "team_mlb_wsn", name: "Nationals", abbreviation: "WSN",
|
||||||
|
sport: .mlb, city: "Washington",
|
||||||
|
stadiumId: "stadium_mlb_nationals_park",
|
||||||
|
primaryColor: "#AB0003", secondaryColor: "#14225A"),
|
||||||
|
"team_mlb_bal": Team(id: "team_mlb_bal", name: "Orioles", abbreviation: "BAL",
|
||||||
|
sport: .mlb, city: "Baltimore",
|
||||||
|
stadiumId: "stadium_mlb_oriole_park_at_camden_yards",
|
||||||
|
primaryColor: "#DF4601", secondaryColor: "#000000"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
selectedTeamIds: teamIds
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: games,
|
||||||
|
teams: teams,
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use April 2, 2026 as current date (the date the bug was reported)
|
||||||
|
let currentDate = TestFixtures.date(year: 2026, month: 4, day: 2, hour: 12)
|
||||||
|
let planner = ScenarioEPlanner(currentDate: currentDate)
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
if case .failure(let failure) = result {
|
||||||
|
Issue.record("Expected success but got failure: \(failure.reason) — \(failure.violations.map(\.description))")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!options.isEmpty, "Should find trip options for PHI/WSN/BAL")
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var output = "\nRESULT> Fixture: \(fixture.url.path)\n"
|
||||||
|
output += "RESULT> MLB rows: \(mlbRows.count), skipped malformed MLB rows: \(skippedMLBRows)\n"
|
||||||
|
output += "RESULT> ========================================\n"
|
||||||
|
output += "RESULT> PHI + WSN + BAL TRIP OPTIONS (\(options.count) results)\n"
|
||||||
|
output += "RESULT> ========================================\n\n"
|
||||||
|
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { "\($0.city)" }.joined(separator: " → ")
|
||||||
|
let dates = option.stops.map { stop in
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMM d"
|
||||||
|
return formatter.string(from: stop.arrivalDate)
|
||||||
|
}.joined(separator: " → ")
|
||||||
|
|
||||||
|
let gameIds = option.stops.flatMap { $0.games }
|
||||||
|
let miles = Int(option.totalDistanceMiles)
|
||||||
|
let hours = String(format: "%.1f", option.totalDrivingHours)
|
||||||
|
|
||||||
|
output += "RESULT> Option #\(option.rank): \(cities)\n"
|
||||||
|
output += "RESULT> Dates: \(dates)\n"
|
||||||
|
output += "RESULT> Driving: \(miles) mi, \(hours) hrs\n"
|
||||||
|
output += "RESULT> Games: \(gameIds.count)\n"
|
||||||
|
output += "RESULT> Rationale: \(option.geographicRationale)\n"
|
||||||
|
|
||||||
|
// Verify all games are in the future
|
||||||
|
for stop in option.stops {
|
||||||
|
#expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate),
|
||||||
|
"Stop in \(stop.city) on \(stop.arrivalDate) should be after April 2, 2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no spring training stadiums
|
||||||
|
for stop in option.stops {
|
||||||
|
let isSpringTraining = stop.city == "Clearwater" || stop.city == "Sarasota" || stop.city == "West Palm Beach"
|
||||||
|
#expect(!isSpringTraining, "Should not include spring training city: \(stop.city)")
|
||||||
|
}
|
||||||
|
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify temporal spread — results should not all be in April
|
||||||
|
let months = Set(options.flatMap { $0.stops.map { calendar.component(.month, from: $0.arrivalDate) } })
|
||||||
|
output += "RESULT> Months covered: \(months.sorted().map { DateFormatter().monthSymbols[$0 - 1] })\n"
|
||||||
|
FileHandle.standardOutput.write(Data(output.utf8))
|
||||||
|
#expect(months.count >= 2, "Results should span multiple months across the season")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON Decoding Helpers
|
||||||
|
|
||||||
|
private struct CanonicalGameJSON: Decodable {
|
||||||
|
let canonical_id: String?
|
||||||
|
let sport: String?
|
||||||
|
let season: String?
|
||||||
|
let game_datetime_utc: String?
|
||||||
|
let home_team_canonical_id: String?
|
||||||
|
let away_team_canonical_id: String?
|
||||||
|
let stadium_canonical_id: String?
|
||||||
|
let is_playoff: Bool?
|
||||||
|
|
||||||
|
var parsedDate: Date {
|
||||||
|
guard let game_datetime_utc else { return Date.distantPast }
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = formatter.date(from: game_datetime_utc) { return date }
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: game_datetime_utc) ?? Date.distantPast
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainGame: Game? {
|
||||||
|
guard let canonical_id,
|
||||||
|
let home_team_canonical_id,
|
||||||
|
let away_team_canonical_id,
|
||||||
|
let stadium_canonical_id,
|
||||||
|
let season else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Game(
|
||||||
|
id: canonical_id,
|
||||||
|
homeTeamId: home_team_canonical_id,
|
||||||
|
awayTeamId: away_team_canonical_id,
|
||||||
|
stadiumId: stadium_canonical_id,
|
||||||
|
dateTime: parsedDate,
|
||||||
|
sport: .mlb,
|
||||||
|
season: season,
|
||||||
|
isPlayoff: is_playoff ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum FixtureLoader {
|
||||||
|
struct LoadedFixture {
|
||||||
|
let url: URL
|
||||||
|
let games: [CanonicalGameJSON]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadCanonicalGames() throws -> LoadedFixture {
|
||||||
|
let candidateURLs = [
|
||||||
|
repositoryRoot.appendingPathComponent("sportstime_export/games_canonical.json"),
|
||||||
|
Bundle(for: BundleToken.self).url(forResource: "games_canonical", withExtension: "json")
|
||||||
|
].compactMap { $0 }
|
||||||
|
|
||||||
|
for url in candidateURLs where FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let games = try JSONDecoder().decode([CanonicalGameJSON].self, from: data)
|
||||||
|
return LoadedFixture(url: url, games: games)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw FixtureLoadError.notFound(candidateURLs.map(\.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var repositoryRoot: URL {
|
||||||
|
URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum FixtureLoadError: LocalizedError {
|
||||||
|
case notFound([String])
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notFound(let paths):
|
||||||
|
let joined = paths.joined(separator: ", ")
|
||||||
|
return "Could not find games_canonical.json. Checked: \(joined)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BundleToken {}
|
||||||
@@ -17,7 +17,7 @@ struct ScenarioEPlannerTests {
|
|||||||
|
|
||||||
// MARK: - Test Data
|
// MARK: - Test Data
|
||||||
|
|
||||||
private let planner = ScenarioEPlanner()
|
private let planner = ScenarioEPlanner(currentDate: TestClock.now)
|
||||||
|
|
||||||
// East Coast coordinates
|
// East Coast coordinates
|
||||||
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
||||||
@@ -548,41 +548,47 @@ struct ScenarioEPlannerTests {
|
|||||||
|
|
||||||
@Test("plan: routes sorted by duration ascending")
|
@Test("plan: routes sorted by duration ascending")
|
||||||
func plan_routesSortedByDurationAscending() {
|
func plan_routesSortedByDurationAscending() {
|
||||||
let baseDate = TestClock.now
|
let calendar = TestClock.calendar
|
||||||
|
let baseDate = calendar.startOfDay(for: TestClock.now)
|
||||||
|
|
||||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
|
||||||
// Create multiple windows with different durations
|
// 2 teams → windowDuration = 4 days. Games must span at least 3 calendar days.
|
||||||
// Window 1: Games on day 1 and 2 (shorter trip)
|
// Window 1: Games on day 1 and day 4 (tighter)
|
||||||
// Window 2: Games on day 10 and 14 (longer trip within window)
|
// Window 2: Games on day 10 and day 13 (separate window)
|
||||||
|
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
|
||||||
|
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
|
||||||
|
let day10Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 10))!
|
||||||
|
let day13Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 13))!
|
||||||
|
|
||||||
let yankeesGame1 = makeGame(
|
let yankeesGame1 = makeGame(
|
||||||
id: "yankees-1",
|
id: "yankees-1",
|
||||||
homeTeamId: "yankees",
|
homeTeamId: "yankees",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "nyc",
|
stadiumId: "nyc",
|
||||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
dateTime: day1Evening
|
||||||
)
|
)
|
||||||
let redsoxGame1 = makeGame(
|
let redsoxGame1 = makeGame(
|
||||||
id: "redsox-1",
|
id: "redsox-1",
|
||||||
homeTeamId: "redsox",
|
homeTeamId: "redsox",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "boston",
|
stadiumId: "boston",
|
||||||
dateTime: baseDate.addingTimeInterval(86400 * 2)
|
dateTime: day4Evening
|
||||||
)
|
)
|
||||||
let yankeesGame2 = makeGame(
|
let yankeesGame2 = makeGame(
|
||||||
id: "yankees-2",
|
id: "yankees-2",
|
||||||
homeTeamId: "yankees",
|
homeTeamId: "yankees",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "nyc",
|
stadiumId: "nyc",
|
||||||
dateTime: baseDate.addingTimeInterval(86400 * 10)
|
dateTime: day10Evening
|
||||||
)
|
)
|
||||||
let redsoxGame2 = makeGame(
|
let redsoxGame2 = makeGame(
|
||||||
id: "redsox-2",
|
id: "redsox-2",
|
||||||
homeTeamId: "redsox",
|
homeTeamId: "redsox",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "boston",
|
stadiumId: "boston",
|
||||||
dateTime: baseDate.addingTimeInterval(86400 * 12)
|
dateTime: day13Evening
|
||||||
)
|
)
|
||||||
|
|
||||||
let prefs = TripPreferences(
|
let prefs = TripPreferences(
|
||||||
@@ -617,30 +623,43 @@ struct ScenarioEPlannerTests {
|
|||||||
for (index, option) in options.enumerated() {
|
for (index, option) in options.enumerated() {
|
||||||
#expect(option.rank == index + 1, "Routes should be ranked 1, 2, 3...")
|
#expect(option.rank == index + 1, "Routes should be ranked 1, 2, 3...")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify actual duration ordering: each option's trip duration <= next option's
|
||||||
|
for i in 0..<(options.count - 1) {
|
||||||
|
let daysA = Calendar.current.dateComponents([.day], from: options[i].stops.first!.arrivalDate, to: options[i].stops.last!.departureDate).day ?? 0
|
||||||
|
let daysB = Calendar.current.dateComponents([.day], from: options[i+1].stops.first!.arrivalDate, to: options[i+1].stops.last!.departureDate).day ?? 0
|
||||||
|
#expect(daysA <= daysB, "Option \(i) duration \(daysA)d should be <= option \(i+1) duration \(daysB)d")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("plan: respects max driving time constraint")
|
@Test("plan: respects max driving time constraint")
|
||||||
func plan_respectsMaxDrivingTimeConstraint() {
|
func plan_respectsMaxDrivingTimeConstraint() {
|
||||||
let baseDate = TestClock.now
|
let calendar = TestClock.calendar
|
||||||
|
let baseDate = calendar.startOfDay(for: TestClock.now)
|
||||||
|
|
||||||
// NYC and LA are ~40 hours apart by car
|
// NYC and LA are ~40 hours apart by car
|
||||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
|
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
|
||||||
|
|
||||||
// Games on consecutive days - impossible to drive between
|
// 2 teams → windowDuration = 4 days. Games must span at least 3 calendar days.
|
||||||
|
// Spread games apart so the window generator produces a valid window,
|
||||||
|
// but keep them on opposite coasts so the driving constraint rejects the route.
|
||||||
|
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
|
||||||
|
let day5Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 5))!
|
||||||
|
|
||||||
let yankeesGame = makeGame(
|
let yankeesGame = makeGame(
|
||||||
id: "yankees-home",
|
id: "yankees-home",
|
||||||
homeTeamId: "yankees",
|
homeTeamId: "yankees",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "nyc",
|
stadiumId: "nyc",
|
||||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
dateTime: day1Evening
|
||||||
)
|
)
|
||||||
let dodgersGame = makeGame(
|
let dodgersGame = makeGame(
|
||||||
id: "dodgers-home",
|
id: "dodgers-home",
|
||||||
homeTeamId: "dodgers",
|
homeTeamId: "dodgers",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "la",
|
stadiumId: "la",
|
||||||
dateTime: baseDate.addingTimeInterval(86400 * 2) // Next day - impossible
|
dateTime: day5Evening
|
||||||
)
|
)
|
||||||
|
|
||||||
let prefs = TripPreferences(
|
let prefs = TripPreferences(
|
||||||
@@ -650,7 +669,7 @@ struct ScenarioEPlannerTests {
|
|||||||
endDate: baseDate.addingTimeInterval(86400 * 30),
|
endDate: baseDate.addingTimeInterval(86400 * 30),
|
||||||
leisureLevel: .moderate,
|
leisureLevel: .moderate,
|
||||||
lodgingType: .hotel,
|
lodgingType: .hotel,
|
||||||
numberOfDrivers: 1, // Single driver, 8 hours max
|
numberOfDrivers: 1, // Single driver, 8 hours max — impossible NYC→LA
|
||||||
selectedTeamIds: ["yankees", "dodgers"]
|
selectedTeamIds: ["yankees", "dodgers"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -666,15 +685,16 @@ struct ScenarioEPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
// Should fail because driving constraint cannot be met
|
// Should fail because driving constraint cannot be met (NYC→LA is ~40h, single driver max 8h/day)
|
||||||
guard case .failure(let failure) = result else {
|
guard case .failure(let failure) = result else {
|
||||||
Issue.record("Expected failure when driving constraint cannot be met")
|
Issue.record("Expected failure when driving constraint cannot be met (NYC→LA with 1 driver, 8h max)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Could be noValidRoutes or constraintsUnsatisfiable
|
// Verify the failure is specifically about driving/route constraints, not a generic error
|
||||||
let validFailures: [PlanningFailure.FailureReason] = [.noValidRoutes, .constraintsUnsatisfiable]
|
let validFailures: [PlanningFailure.FailureReason] = [.noValidRoutes, .constraintsUnsatisfiable, .drivingExceedsLimit]
|
||||||
#expect(validFailures.contains { $0 == failure.reason }, "Should fail due to route constraints")
|
#expect(validFailures.contains { $0 == failure.reason },
|
||||||
|
"Expected driving-related failure (.noValidRoutes, .constraintsUnsatisfiable, or .drivingExceedsLimit), got \(failure.reason)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - E4. Edge Case Tests
|
// MARK: - E4. Edge Case Tests
|
||||||
@@ -989,31 +1009,39 @@ struct ScenarioEPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
#expect(options.count <= 10, "Should return at most 10 results")
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#expect(options.count <= 10, "Should return at most 10 results")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Invariant: all routes contain home games from all selected teams")
|
@Test("Invariant: all routes contain home games from all selected teams")
|
||||||
func invariant_allRoutesContainAllSelectedTeams() {
|
func invariant_allRoutesContainAllSelectedTeams() {
|
||||||
let baseDate = TestClock.now
|
let calendar = TestClock.calendar
|
||||||
|
let baseDate = calendar.startOfDay(for: TestClock.now)
|
||||||
|
|
||||||
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
|
||||||
|
// 2 teams → windowDuration = 4 days. Games must span at least 3 calendar days.
|
||||||
|
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
|
||||||
|
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
|
||||||
|
|
||||||
let yankeesGame = makeGame(
|
let yankeesGame = makeGame(
|
||||||
id: "yankees-home",
|
id: "yankees-home",
|
||||||
homeTeamId: "yankees",
|
homeTeamId: "yankees",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "nyc",
|
stadiumId: "nyc",
|
||||||
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
dateTime: day1Evening
|
||||||
)
|
)
|
||||||
let redsoxGame = makeGame(
|
let redsoxGame = makeGame(
|
||||||
id: "redsox-home",
|
id: "redsox-home",
|
||||||
homeTeamId: "redsox",
|
homeTeamId: "redsox",
|
||||||
awayTeamId: "opponent",
|
awayTeamId: "opponent",
|
||||||
stadiumId: "boston",
|
stadiumId: "boston",
|
||||||
dateTime: baseDate.addingTimeInterval(86400 * 2)
|
dateTime: day4Evening
|
||||||
)
|
)
|
||||||
|
|
||||||
let prefs = TripPreferences(
|
let prefs = TripPreferences(
|
||||||
@@ -1039,7 +1067,11 @@ struct ScenarioEPlannerTests {
|
|||||||
|
|
||||||
let result = planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
if case .success(let options) = result {
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for option in options {
|
for option in options {
|
||||||
let allGameIds = Set(option.stops.flatMap { $0.games })
|
let allGameIds = Set(option.stops.flatMap { $0.games })
|
||||||
|
|
||||||
@@ -1056,6 +1088,450 @@ struct ScenarioEPlannerTests {
|
|||||||
#expect(hasRedsoxGame, "Every route must include a Red Sox home game")
|
#expect(hasRedsoxGame, "Every route must include a Red Sox home game")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Region Filter Tests
|
||||||
|
|
||||||
|
@Test("teamFirst: east region only excludes west games")
|
||||||
|
func teamFirst_eastRegionOnly_excludesWestGames() {
|
||||||
|
// Create two teams: one east (NYC), one also east (Boston)
|
||||||
|
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York")
|
||||||
|
let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston")
|
||||||
|
|
||||||
|
let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York")
|
||||||
|
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
|
||||||
|
let stadiumLA = TestFixtures.stadium(id: "stadium_la", city: "Los Angeles")
|
||||||
|
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
|
||||||
|
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
|
||||||
|
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day2, homeTeamId: "team_bos", stadiumId: "stadium_bos")
|
||||||
|
// LA game should be excluded by east-only filter
|
||||||
|
let gameLA = TestFixtures.game(id: "game_la", city: "Los Angeles", dateTime: day3, homeTeamId: "team_nyc", stadiumId: "stadium_la")
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
selectedRegions: [.east], // East only
|
||||||
|
selectedTeamIds: ["team_nyc", "team_bos"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameNYC, gameBOS, gameLA],
|
||||||
|
teams: ["team_nyc": teamNYC, "team_bos": teamBOS],
|
||||||
|
stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS, "stadium_la": stadiumLA]
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioEPlanner()
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
// Should succeed — both teams have east coast games.
|
||||||
|
// Failure is also acceptable if routing constraints prevent a valid route.
|
||||||
|
switch result {
|
||||||
|
case .success(let options):
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { $0.city }
|
||||||
|
#expect(!cities.contains("Los Angeles"), "East-only filter should exclude LA")
|
||||||
|
}
|
||||||
|
case .failure:
|
||||||
|
break // Acceptable — routing constraints may prevent a valid route
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("teamFirst: all regions includes everything")
|
||||||
|
func teamFirst_allRegions_includesEverything() {
|
||||||
|
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York")
|
||||||
|
let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston")
|
||||||
|
|
||||||
|
let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York")
|
||||||
|
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
|
||||||
|
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
// 2 teams → windowDuration = 4 days. Games must be within 3 days to fit in a single window.
|
||||||
|
let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
|
||||||
|
|
||||||
|
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
|
||||||
|
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day4, homeTeamId: "team_bos", stadiumId: "stadium_bos")
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
numberOfDrivers: 2,
|
||||||
|
selectedRegions: [.east, .central, .west], // All regions
|
||||||
|
selectedTeamIds: ["team_nyc", "team_bos"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameNYC, gameBOS],
|
||||||
|
teams: ["team_nyc": teamNYC, "team_bos": teamBOS],
|
||||||
|
stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS]
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioEPlanner()
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
// With all regions and nearby east-coast cities, planning should succeed
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success with all regions and feasible route, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(!options.isEmpty, "Should return at least one route option")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("teamFirst: empty regions includes everything")
|
||||||
|
func teamFirst_emptyRegions_includesEverything() {
|
||||||
|
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York")
|
||||||
|
let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston")
|
||||||
|
|
||||||
|
let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York")
|
||||||
|
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
|
||||||
|
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
// 2 teams → windowDuration = 4 days. Games must be within 3 days to fit in a single window.
|
||||||
|
let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
|
||||||
|
|
||||||
|
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
|
||||||
|
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day4, homeTeamId: "team_bos", stadiumId: "stadium_bos")
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
numberOfDrivers: 2,
|
||||||
|
selectedRegions: [], // Empty = no filtering
|
||||||
|
selectedTeamIds: ["team_nyc", "team_bos"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [gameNYC, gameBOS],
|
||||||
|
teams: ["team_nyc": teamNYC, "team_bos": teamBOS],
|
||||||
|
stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS]
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioEPlanner()
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
// Empty regions = no filtering, so both games should be available and route feasible
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success with empty regions (no filtering) and feasible route, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(!options.isEmpty, "Should return at least one route option")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Past Date Filtering Tests
|
||||||
|
|
||||||
|
@Test("teamFirst: past-only games return noValidRoutes")
|
||||||
|
func teamFirst_pastOnlyGames_returnsNoResults() {
|
||||||
|
// Simulate: currentDate is June 1, but all games are in March (past)
|
||||||
|
let calendar = TestClock.calendar
|
||||||
|
let currentDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 12)
|
||||||
|
let pastDate = TestFixtures.date(year: 2026, month: 3, day: 10, hour: 19)
|
||||||
|
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
|
||||||
|
let pastGame1 = makeGame(id: "past-yankees", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc", dateTime: pastDate)
|
||||||
|
let pastGame2 = makeGame(id: "past-redsox", homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "boston",
|
||||||
|
dateTime: calendar.date(byAdding: .day, value: 2, to: pastDate)!)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
selectedTeamIds: ["yankees", "redsox"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [pastGame1, pastGame2],
|
||||||
|
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||||
|
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
|
||||||
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioEPlanner(currentDate: currentDate)
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
// All games are in the past — no valid windows should exist
|
||||||
|
guard case .failure(let failure) = result else {
|
||||||
|
Issue.record("Expected failure when all games are in the past")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(failure.reason == .noValidRoutes, "Should fail with noValidRoutes when all windows are in the past")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("teamFirst: mix of past and future games only returns future windows")
|
||||||
|
func teamFirst_mixPastFuture_onlyReturnsFutureWindows() {
|
||||||
|
let calendar = TestClock.calendar
|
||||||
|
// Current date: March 15, 2026
|
||||||
|
let currentDate = TestFixtures.date(year: 2026, month: 3, day: 15, hour: 12)
|
||||||
|
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
|
||||||
|
// Past games (early March)
|
||||||
|
let pastGame1 = makeGame(id: "past-yankees", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc",
|
||||||
|
dateTime: TestFixtures.date(year: 2026, month: 3, day: 2, hour: 19))
|
||||||
|
let pastGame2 = makeGame(id: "past-redsox", homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "boston",
|
||||||
|
dateTime: TestFixtures.date(year: 2026, month: 3, day: 3, hour: 19))
|
||||||
|
|
||||||
|
// Future games (late March / April)
|
||||||
|
let futureGame1 = makeGame(id: "future-yankees", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc",
|
||||||
|
dateTime: TestFixtures.date(year: 2026, month: 3, day: 20, hour: 19))
|
||||||
|
let futureGame2 = makeGame(id: "future-redsox", homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "boston",
|
||||||
|
dateTime: TestFixtures.date(year: 2026, month: 3, day: 22, hour: 19))
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
selectedTeamIds: ["yankees", "redsox"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [pastGame1, pastGame2, futureGame1, futureGame2],
|
||||||
|
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||||
|
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
|
||||||
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioEPlanner(currentDate: currentDate)
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returned stops should be on or after currentDate
|
||||||
|
for option in options {
|
||||||
|
for stop in option.stops {
|
||||||
|
#expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate),
|
||||||
|
"All stops should be in the future, got \(stop.arrivalDate)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("teamFirst: evaluates all sampled windows across full season")
|
||||||
|
func teamFirst_evaluatesAllSampledWindows_fullSeasonCoverage() {
|
||||||
|
let calendar = TestClock.calendar
|
||||||
|
let currentDate = TestFixtures.date(year: 2026, month: 4, day: 1, hour: 12)
|
||||||
|
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
|
||||||
|
// Create games spread across April-September (6 months)
|
||||||
|
// Each team gets a home game every ~10 days
|
||||||
|
var allGames: [Game] = []
|
||||||
|
for monthOffset in 0..<6 {
|
||||||
|
let month = 4 + monthOffset
|
||||||
|
for dayOffset in stride(from: 1, through: 25, by: 10) {
|
||||||
|
let gameDate = TestFixtures.date(year: 2026, month: month, day: dayOffset, hour: 19)
|
||||||
|
allGames.append(makeGame(
|
||||||
|
id: "yankees-\(month)-\(dayOffset)",
|
||||||
|
homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc",
|
||||||
|
dateTime: gameDate
|
||||||
|
))
|
||||||
|
let gameDate2 = calendar.date(byAdding: .day, value: 1, to: gameDate)!
|
||||||
|
allGames.append(makeGame(
|
||||||
|
id: "redsox-\(month)-\(dayOffset)",
|
||||||
|
homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "boston",
|
||||||
|
dateTime: gameDate2
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
selectedTeamIds: ["yankees", "redsox"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: allGames,
|
||||||
|
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||||
|
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
|
||||||
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioEPlanner(currentDate: currentDate)
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected success with games spread across full season")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!options.isEmpty, "Should find options across the season")
|
||||||
|
|
||||||
|
// Verify results span multiple months (not clustered in first month)
|
||||||
|
let months = Set(options.flatMap { option in
|
||||||
|
option.stops.map { calendar.component(.month, from: $0.arrivalDate) }
|
||||||
|
})
|
||||||
|
#expect(months.count >= 2, "Results should span at least 2 months, got months: \(months.sorted())")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Output Sanity
|
||||||
|
|
||||||
|
@Test("plan: all stop dates in the future (synthetic regression)")
|
||||||
|
func plan_allStopDatesInFuture_syntheticRegression() {
|
||||||
|
// Regression for the PHI/WSN/BAL bug: past spring training games in output
|
||||||
|
let currentDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 12)
|
||||||
|
let calendar = TestClock.calendar
|
||||||
|
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
|
||||||
|
// Mix of past and future games
|
||||||
|
let pastGame1 = makeGame(id: "past-nyc", homeTeamId: "yankees", awayTeamId: "opp",
|
||||||
|
stadiumId: "nyc",
|
||||||
|
dateTime: TestFixtures.date(year: 2026, month: 3, day: 10, hour: 13))
|
||||||
|
let pastGame2 = makeGame(id: "past-bos", homeTeamId: "redsox", awayTeamId: "opp",
|
||||||
|
stadiumId: "boston",
|
||||||
|
dateTime: TestFixtures.date(year: 2026, month: 3, day: 12, hour: 13))
|
||||||
|
let futureGame1 = makeGame(id: "future-nyc", homeTeamId: "yankees", awayTeamId: "opp",
|
||||||
|
stadiumId: "nyc",
|
||||||
|
dateTime: TestFixtures.date(year: 2026, month: 6, day: 5, hour: 19))
|
||||||
|
let futureGame2 = makeGame(id: "future-bos", homeTeamId: "redsox", awayTeamId: "opp",
|
||||||
|
stadiumId: "boston",
|
||||||
|
dateTime: TestFixtures.date(year: 2026, month: 6, day: 7, hour: 19))
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
numberOfDrivers: 2,
|
||||||
|
selectedTeamIds: ["yankees", "redsox"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [pastGame1, pastGame2, futureGame1, futureGame2],
|
||||||
|
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||||
|
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
|
||||||
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioEPlanner(currentDate: currentDate)
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let startOfDay = calendar.startOfDay(for: currentDate)
|
||||||
|
for option in options {
|
||||||
|
for stop in option.stops {
|
||||||
|
#expect(stop.arrivalDate >= startOfDay,
|
||||||
|
"Stop on \(stop.arrivalDate) is before currentDate \(startOfDay)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("plan: results cover multiple months when games spread across season")
|
||||||
|
func plan_resultsCoverMultipleMonths() {
|
||||||
|
let currentDate = TestFixtures.date(year: 2026, month: 4, day: 1, hour: 12)
|
||||||
|
let calendar = TestClock.calendar
|
||||||
|
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
|
||||||
|
var games: [Game] = []
|
||||||
|
for month in 4...9 {
|
||||||
|
let dt1 = TestFixtures.date(year: 2026, month: month, day: 5, hour: 19)
|
||||||
|
let dt2 = TestFixtures.date(year: 2026, month: month, day: 7, hour: 19)
|
||||||
|
games.append(makeGame(id: "nyc-\(month)", homeTeamId: "yankees", awayTeamId: "opp",
|
||||||
|
stadiumId: "nyc", dateTime: dt1))
|
||||||
|
games.append(makeGame(id: "bos-\(month)", homeTeamId: "redsox", awayTeamId: "opp",
|
||||||
|
stadiumId: "boston", dateTime: dt2))
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
numberOfDrivers: 2,
|
||||||
|
selectedTeamIds: ["yankees", "redsox"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: games,
|
||||||
|
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||||
|
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
|
||||||
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioEPlanner(currentDate: currentDate)
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(options.count >= 2, "Should have multiple options across season")
|
||||||
|
let months = Set(options.flatMap { opt in
|
||||||
|
opt.stops.map { calendar.component(.month, from: $0.arrivalDate) }
|
||||||
|
})
|
||||||
|
#expect(months.count >= 2,
|
||||||
|
"Results should span multiple months, got: \(months.sorted())")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("plan: every option has all selected teams")
|
||||||
|
func plan_everyOptionHasAllSelectedTeams_tighter() {
|
||||||
|
let currentDate = TestClock.now
|
||||||
|
|
||||||
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
||||||
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
||||||
|
|
||||||
|
var games: [Game] = []
|
||||||
|
for day in stride(from: 1, through: 30, by: 3) {
|
||||||
|
games.append(makeGame(id: "nyc-\(day)", homeTeamId: "yankees", awayTeamId: "opp",
|
||||||
|
stadiumId: "nyc",
|
||||||
|
dateTime: TestClock.addingDays(day)))
|
||||||
|
games.append(makeGame(id: "bos-\(day)", homeTeamId: "redsox", awayTeamId: "opp",
|
||||||
|
stadiumId: "boston",
|
||||||
|
dateTime: TestClock.addingDays(day + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
numberOfDrivers: 2,
|
||||||
|
selectedTeamIds: ["yankees", "redsox"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: games,
|
||||||
|
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
|
||||||
|
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
|
||||||
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
let gameMap = Dictionary(games.map { ($0.id, $0) }, uniquingKeysWith: { f, _ in f })
|
||||||
|
let planner = ScenarioEPlanner(currentDate: currentDate)
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
Issue.record("Expected .success, got \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, option) in options.enumerated() {
|
||||||
|
let homeTeams = Set(
|
||||||
|
option.stops.flatMap { $0.games }
|
||||||
|
.compactMap { gameMap[$0]?.homeTeamId }
|
||||||
|
)
|
||||||
|
#expect(homeTeams.contains("yankees"),
|
||||||
|
"Option \(idx): missing Yankees home game")
|
||||||
|
#expect(homeTeams.contains("redsox"),
|
||||||
|
"Option \(idx): missing Red Sox home game")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|||||||
@@ -149,6 +149,44 @@ struct ScenarioPlannerFactoryTests {
|
|||||||
#expect(planner is ScenarioBPlanner, "B should take priority over C")
|
#expect(planner is ScenarioBPlanner, "B should take priority over C")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("planner: teamFirst with 2+ teams returns ScenarioEPlanner")
|
||||||
|
func planner_teamFirst_returnsScenarioE() {
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: TestClock.now,
|
||||||
|
endDate: TestClock.now.addingTimeInterval(86400 * 7),
|
||||||
|
leisureLevel: .moderate,
|
||||||
|
lodgingType: .hotel,
|
||||||
|
numberOfDrivers: 1,
|
||||||
|
selectedTeamIds: ["team-1", "team-2"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = makeRequest(preferences: prefs)
|
||||||
|
let planner = ScenarioPlannerFactory.planner(for: request)
|
||||||
|
|
||||||
|
#expect(planner is ScenarioEPlanner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("classify: teamFirst with 2+ teams returns scenarioE")
|
||||||
|
func classify_teamFirst_returnsScenarioE() {
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: TestClock.now,
|
||||||
|
endDate: TestClock.now.addingTimeInterval(86400 * 7),
|
||||||
|
leisureLevel: .moderate,
|
||||||
|
lodgingType: .hotel,
|
||||||
|
numberOfDrivers: 1,
|
||||||
|
selectedTeamIds: ["team-1", "team-2"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = makeRequest(preferences: prefs)
|
||||||
|
let scenario = ScenarioPlannerFactory.classify(request)
|
||||||
|
|
||||||
|
#expect(scenario == .scenarioE)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: classify()
|
// MARK: - Specification Tests: classify()
|
||||||
|
|
||||||
@Test("classify: followTeamId returns scenarioD")
|
@Test("classify: followTeamId returns scenarioD")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ struct TeamFirstIntegrationTests {
|
|||||||
|
|
||||||
// MARK: - Test Data
|
// MARK: - Test Data
|
||||||
|
|
||||||
private let planner = ScenarioEPlanner()
|
private let planner = ScenarioEPlanner(currentDate: TestClock.now)
|
||||||
|
|
||||||
// MLB stadiums with realistic coordinates
|
// MLB stadiums with realistic coordinates
|
||||||
private let yankeeStadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262)
|
private let yankeeStadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262)
|
||||||
|
|||||||
@@ -69,26 +69,6 @@ struct TravelEstimatorTests {
|
|||||||
#expect(abs(convertedMiles - miles) < 1.0) // Within 1 mile tolerance
|
#expect(abs(convertedMiles - miles) < 1.0) // Within 1 mile tolerance
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: estimateFallbackDistance
|
|
||||||
|
|
||||||
@Test("estimateFallbackDistance: same city returns zero")
|
|
||||||
func estimateFallbackDistance_sameCity_returnsZero() {
|
|
||||||
let from = makeStop(city: "New York")
|
|
||||||
let to = makeStop(city: "New York")
|
|
||||||
|
|
||||||
let distance = TravelEstimator.estimateFallbackDistance(from: from, to: to)
|
|
||||||
#expect(distance == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("estimateFallbackDistance: different cities returns 300 miles")
|
|
||||||
func estimateFallbackDistance_differentCities_returns300() {
|
|
||||||
let from = makeStop(city: "New York")
|
|
||||||
let to = makeStop(city: "Boston")
|
|
||||||
|
|
||||||
let distance = TravelEstimator.estimateFallbackDistance(from: from, to: to)
|
|
||||||
#expect(distance == 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Specification Tests: calculateDistanceMiles
|
// MARK: - Specification Tests: calculateDistanceMiles
|
||||||
|
|
||||||
@Test("calculateDistanceMiles: with coordinates uses Haversine times routing factor")
|
@Test("calculateDistanceMiles: with coordinates uses Haversine times routing factor")
|
||||||
@@ -100,25 +80,26 @@ struct TravelEstimatorTests {
|
|||||||
let haversine = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston)
|
let haversine = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston)
|
||||||
|
|
||||||
// Road distance = Haversine * 1.3
|
// Road distance = Haversine * 1.3
|
||||||
#expect(abs(distance - haversine * 1.3) < 0.1)
|
#expect(distance != nil)
|
||||||
|
#expect(abs(distance! - haversine * 1.3) < 0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("calculateDistanceMiles: missing coordinates uses fallback")
|
@Test("calculateDistanceMiles: missing coordinates returns nil")
|
||||||
func calculateDistanceMiles_missingCoordinates_usesFallback() {
|
func calculateDistanceMiles_missingCoordinates_returnsNil() {
|
||||||
let from = makeStop(city: "New York", coordinate: nil)
|
let from = makeStop(city: "New York", coordinate: nil)
|
||||||
let to = makeStop(city: "Boston", coordinate: nil)
|
let to = makeStop(city: "Boston", coordinate: nil)
|
||||||
|
|
||||||
let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
||||||
#expect(distance == 300) // Fallback distance
|
#expect(distance == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("calculateDistanceMiles: same city without coordinates returns zero")
|
@Test("calculateDistanceMiles: one missing coordinate returns nil")
|
||||||
func calculateDistanceMiles_sameCityNoCoords_returnsZero() {
|
func calculateDistanceMiles_oneMissingCoordinate_returnsNil() {
|
||||||
let from = makeStop(city: "New York", coordinate: nil)
|
let from = makeStop(city: "New York", coordinate: nyc)
|
||||||
let to = makeStop(city: "New York", coordinate: nil)
|
let to = makeStop(city: "Boston", coordinate: nil)
|
||||||
|
|
||||||
let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
||||||
#expect(distance == 0)
|
#expect(distance == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: estimate(ItineraryStop, ItineraryStop)
|
// MARK: - Specification Tests: estimate(ItineraryStop, ItineraryStop)
|
||||||
@@ -142,7 +123,7 @@ struct TravelEstimatorTests {
|
|||||||
|
|
||||||
let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints)!
|
let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints)!
|
||||||
|
|
||||||
let expectedMiles = TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
let expectedMiles = TravelEstimator.calculateDistanceMiles(from: from, to: to)!
|
||||||
let expectedMeters = expectedMiles * 1609.34
|
let expectedMeters = expectedMiles * 1609.34
|
||||||
let expectedHours = expectedMiles / 60.0
|
let expectedHours = expectedMiles / 60.0
|
||||||
let expectedSeconds = expectedHours * 3600
|
let expectedSeconds = expectedHours * 3600
|
||||||
@@ -251,18 +232,16 @@ struct TravelEstimatorTests {
|
|||||||
|
|
||||||
@Test("calculateTravelDays: all dates are start of day")
|
@Test("calculateTravelDays: all dates are start of day")
|
||||||
func calculateTravelDays_allDatesAreStartOfDay() {
|
func calculateTravelDays_allDatesAreStartOfDay() {
|
||||||
let calendar = TestClock.calendar
|
// Production calculateTravelDays uses Calendar.current for startOfDay,
|
||||||
// Use a specific time that's not midnight
|
// so assert with Calendar.current to match.
|
||||||
var components = calendar.dateComponents([.year, .month, .day], from: TestClock.now)
|
let departure = TestFixtures.date(year: 2026, month: 6, day: 15, hour: 14, minute: 30)
|
||||||
components.hour = 14
|
|
||||||
components.minute = 30
|
|
||||||
let departure = calendar.date(from: components)!
|
|
||||||
|
|
||||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 20)
|
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 20)
|
||||||
|
|
||||||
|
let systemCalendar = Calendar.current
|
||||||
for day in days {
|
for day in days {
|
||||||
let hour = calendar.component(.hour, from: day)
|
let hour = systemCalendar.component(.hour, from: day)
|
||||||
let minute = calendar.component(.minute, from: day)
|
let minute = systemCalendar.component(.minute, from: day)
|
||||||
#expect(hour == 0 && minute == 0, "Expected midnight, got \(hour):\(minute)")
|
#expect(hour == 0 && minute == 0, "Expected midnight, got \(hour):\(minute)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +306,7 @@ struct TravelEstimatorTests {
|
|||||||
let from = makeStop(city: "New York", coordinate: nyc)
|
let from = makeStop(city: "New York", coordinate: nyc)
|
||||||
let to = makeStop(city: "Boston", coordinate: boston)
|
let to = makeStop(city: "Boston", coordinate: boston)
|
||||||
|
|
||||||
let roadDistance = TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
let roadDistance = TravelEstimator.calculateDistanceMiles(from: from, to: to)!
|
||||||
let straightLine = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston)
|
let straightLine = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston)
|
||||||
|
|
||||||
#expect(roadDistance >= straightLine, "Road distance should be >= straight line")
|
#expect(roadDistance >= straightLine, "Road distance should be >= straight line")
|
||||||
@@ -430,7 +409,7 @@ struct TravelEstimatorTests {
|
|||||||
func edge_negativeDrivingHours() {
|
func edge_negativeDrivingHours() {
|
||||||
let departure = TestClock.now
|
let departure = TestClock.now
|
||||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: -5)
|
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: -5)
|
||||||
#expect(days.count >= 1, "Negative hours should still return at least 1 day")
|
#expect(days.count == 1, "Negative hours should be treated as zero driving, returning exactly 1 day")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|||||||
797
SportsTimeTests/Planning/TravelSegmentIntegrityTests.swift
Normal file
797
SportsTimeTests/Planning/TravelSegmentIntegrityTests.swift
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
//
|
||||||
|
// TravelSegmentIntegrityTests.swift
|
||||||
|
// SportsTimeTests
|
||||||
|
//
|
||||||
|
// CRITICAL INVARIANT: Every itinerary option returned to users MUST have
|
||||||
|
// valid travel segments between ALL consecutive stops.
|
||||||
|
//
|
||||||
|
// N stops → exactly N-1 travel segments. No exceptions.
|
||||||
|
//
|
||||||
|
// This file tests the invariant at every layer:
|
||||||
|
// 1. ItineraryBuilder.build() — the segment factory
|
||||||
|
// 2. ItineraryOption.isValid — the runtime check
|
||||||
|
// 3. TripPlanningEngine — the final gate
|
||||||
|
// 4. Each scenario planner (A-E) — end-to-end
|
||||||
|
// 5. Edge cases — single stops, same-city, missing coords, cross-country
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
@testable import SportsTime
|
||||||
|
|
||||||
|
// MARK: - Layer 1: ItineraryBuilder Invariant
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: ItineraryBuilder")
|
||||||
|
struct TravelIntegrity_BuilderTests {
|
||||||
|
|
||||||
|
@Test("build: 2 stops → exactly 1 segment")
|
||||||
|
func build_twoStops_oneSegment() {
|
||||||
|
let nyc = TestFixtures.coordinates["New York"]!
|
||||||
|
let boston = TestFixtures.coordinates["Boston"]!
|
||||||
|
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "New York", coord: nyc, day: 0),
|
||||||
|
makeStop(city: "Boston", coord: boston, day: 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(result != nil, "NYC→Boston should build")
|
||||||
|
#expect(result!.travelSegments.count == 1, "2 stops must have exactly 1 segment")
|
||||||
|
#expect(result!.travelSegments[0].estimatedDistanceMiles > 0, "Segment must have distance")
|
||||||
|
#expect(result!.travelSegments[0].estimatedDrivingHours > 0, "Segment must have duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build: 3 stops → exactly 2 segments")
|
||||||
|
func build_threeStops_twoSegments() {
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
|
||||||
|
makeStop(city: "Philadelphia", coord: TestFixtures.coordinates["Philadelphia"]!, day: 1),
|
||||||
|
makeStop(city: "Boston", coord: TestFixtures.coordinates["Boston"]!, day: 2)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(result != nil)
|
||||||
|
#expect(result!.travelSegments.count == 2, "3 stops must have exactly 2 segments")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build: 5 stops → exactly 4 segments")
|
||||||
|
func build_fiveStops_fourSegments() {
|
||||||
|
let cities = ["New York", "Philadelphia", "Boston", "Chicago", "Detroit"]
|
||||||
|
let stops = cities.enumerated().map { i, city in
|
||||||
|
makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i)
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(result != nil)
|
||||||
|
#expect(result!.travelSegments.count == 4, "5 stops must have exactly 4 segments")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build: single stop → 0 segments")
|
||||||
|
func build_singleStop_noSegments() {
|
||||||
|
let stops = [makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0)]
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(result != nil)
|
||||||
|
#expect(result!.travelSegments.isEmpty, "1 stop must have 0 segments")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build: empty stops → 0 segments")
|
||||||
|
func build_emptyStops_noSegments() {
|
||||||
|
let result = ItineraryBuilder.build(stops: [], constraints: .default)
|
||||||
|
#expect(result != nil)
|
||||||
|
#expect(result!.travelSegments.isEmpty)
|
||||||
|
#expect(result!.stops.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build: missing coordinates → returns nil (not partial)")
|
||||||
|
func build_missingCoords_returnsNil() {
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
|
||||||
|
makeStop(city: "Atlantis", coord: nil, day: 1), // No coords!
|
||||||
|
makeStop(city: "Boston", coord: TestFixtures.coordinates["Boston"]!, day: 2)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(result == nil, "Missing coordinates must reject entire itinerary, not produce partial")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build: infeasible segment → returns nil (not partial)")
|
||||||
|
func build_infeasibleSegment_returnsNil() {
|
||||||
|
// Use extremely tight constraints to make cross-country infeasible
|
||||||
|
let tightConstraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 1.0)
|
||||||
|
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
|
||||||
|
makeStop(city: "Los Angeles", coord: TestFixtures.coordinates["Los Angeles"]!, day: 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
// NYC→LA is ~2,800 miles. With 1 hour/day max, exceeds 5x limit (5 hours)
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: tightConstraints)
|
||||||
|
#expect(result == nil, "Infeasible segment must reject entire itinerary")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build: every segment connects the correct stops in order")
|
||||||
|
func build_segmentOrder_matchesStops() {
|
||||||
|
let cities = ["New York", "Philadelphia", "Boston"]
|
||||||
|
let stops = cities.enumerated().map { i, city in
|
||||||
|
makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i)
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(result != nil)
|
||||||
|
|
||||||
|
// Verify segment endpoints match stop pairs
|
||||||
|
for i in 0..<result!.travelSegments.count {
|
||||||
|
let segment = result!.travelSegments[i]
|
||||||
|
let fromStop = result!.stops[i]
|
||||||
|
let toStop = result!.stops[i + 1]
|
||||||
|
|
||||||
|
#expect(segment.fromLocation.name == fromStop.city,
|
||||||
|
"Segment \(i) fromLocation must match stop \(i) city")
|
||||||
|
#expect(segment.toLocation.name == toStop.city,
|
||||||
|
"Segment \(i) toLocation must match stop \(i+1) city")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build: segment validator rejection → returns nil (not partial)")
|
||||||
|
func build_validatorRejection_returnsNil() {
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
|
||||||
|
makeStop(city: "Boston", coord: TestFixtures.coordinates["Boston"]!, day: 0) // Same day
|
||||||
|
]
|
||||||
|
|
||||||
|
// Validator always rejects
|
||||||
|
let alwaysReject: ItineraryBuilder.SegmentValidator = { _, _, _ in false }
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(
|
||||||
|
stops: stops, constraints: .default, segmentValidator: alwaysReject
|
||||||
|
)
|
||||||
|
#expect(result == nil, "Rejected validator must fail entire build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer 2: ItineraryOption.isValid
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: isValid Property")
|
||||||
|
struct TravelIntegrity_IsValidTests {
|
||||||
|
|
||||||
|
@Test("isValid: correct segment count → true")
|
||||||
|
func isValid_correct_true() {
|
||||||
|
let segment = TestFixtures.travelSegment(from: "New York", to: "Boston")
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 1,
|
||||||
|
stops: [
|
||||||
|
makeStop(city: "New York", coord: nil, day: 0),
|
||||||
|
makeStop(city: "Boston", coord: nil, day: 1)
|
||||||
|
],
|
||||||
|
travelSegments: [segment],
|
||||||
|
totalDrivingHours: 3.5, totalDistanceMiles: 215,
|
||||||
|
geographicRationale: "test"
|
||||||
|
)
|
||||||
|
#expect(option.isValid == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isValid: too few segments → false")
|
||||||
|
func isValid_tooFew_false() {
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 1,
|
||||||
|
stops: [
|
||||||
|
makeStop(city: "New York", coord: nil, day: 0),
|
||||||
|
makeStop(city: "Boston", coord: nil, day: 1),
|
||||||
|
makeStop(city: "Chicago", coord: nil, day: 2)
|
||||||
|
],
|
||||||
|
travelSegments: [TestFixtures.travelSegment(from: "New York", to: "Boston")],
|
||||||
|
// Only 1 segment for 3 stops — INVALID
|
||||||
|
totalDrivingHours: 3.5, totalDistanceMiles: 215,
|
||||||
|
geographicRationale: "test"
|
||||||
|
)
|
||||||
|
#expect(option.isValid == false, "3 stops with 1 segment must be invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isValid: too many segments → false")
|
||||||
|
func isValid_tooMany_false() {
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 1,
|
||||||
|
stops: [
|
||||||
|
makeStop(city: "New York", coord: nil, day: 0),
|
||||||
|
makeStop(city: "Boston", coord: nil, day: 1)
|
||||||
|
],
|
||||||
|
travelSegments: [
|
||||||
|
TestFixtures.travelSegment(from: "New York", to: "Boston"),
|
||||||
|
TestFixtures.travelSegment(from: "Boston", to: "Chicago")
|
||||||
|
],
|
||||||
|
// 2 segments for 2 stops — INVALID
|
||||||
|
totalDrivingHours: 10, totalDistanceMiles: 800,
|
||||||
|
geographicRationale: "test"
|
||||||
|
)
|
||||||
|
#expect(option.isValid == false, "2 stops with 2 segments must be invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isValid: 0 stops with 0 segments → true")
|
||||||
|
func isValid_empty_true() {
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 1, stops: [], travelSegments: [],
|
||||||
|
totalDrivingHours: 0, totalDistanceMiles: 0,
|
||||||
|
geographicRationale: "empty"
|
||||||
|
)
|
||||||
|
#expect(option.isValid == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isValid: 1 stop with 0 segments → true")
|
||||||
|
func isValid_singleStop_true() {
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 1,
|
||||||
|
stops: [makeStop(city: "New York", coord: nil, day: 0)],
|
||||||
|
travelSegments: [],
|
||||||
|
totalDrivingHours: 0, totalDistanceMiles: 0,
|
||||||
|
geographicRationale: "single"
|
||||||
|
)
|
||||||
|
#expect(option.isValid == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isValid: 1 stop with 1 segment → false (orphan segment)")
|
||||||
|
func isValid_singleStopWithSegment_false() {
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 1,
|
||||||
|
stops: [makeStop(city: "New York", coord: nil, day: 0)],
|
||||||
|
travelSegments: [TestFixtures.travelSegment(from: "New York", to: "Boston")],
|
||||||
|
totalDrivingHours: 3.5, totalDistanceMiles: 215,
|
||||||
|
geographicRationale: "orphan segment"
|
||||||
|
)
|
||||||
|
#expect(option.isValid == false, "1 stop with segments must be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer 3: TripPlanningEngine Final Gate
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: Engine Final Gate")
|
||||||
|
struct TravelIntegrity_EngineGateTests {
|
||||||
|
|
||||||
|
@Test("Engine never returns options where isValid is false")
|
||||||
|
func engine_neverReturnsInvalid() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
|
||||||
|
// Generate realistic games across multiple days
|
||||||
|
let cities = ["New York", "Boston", "Philadelphia", "Chicago", "Atlanta"]
|
||||||
|
let games = cities.enumerated().map { i, city in
|
||||||
|
TestFixtures.game(
|
||||||
|
city: city,
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: games,
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
for (i, option) in options.enumerated() {
|
||||||
|
#expect(option.isValid,
|
||||||
|
"Option \(i) has \(option.stops.count) stops but \(option.travelSegments.count) segments — INVALID")
|
||||||
|
// Double-check the math
|
||||||
|
if option.stops.count > 1 {
|
||||||
|
#expect(option.travelSegments.count == option.stops.count - 1,
|
||||||
|
"Option \(i): \(option.stops.count) stops must have \(option.stops.count - 1) segments, got \(option.travelSegments.count)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Engine rejects all-invalid options with segmentMismatch failure")
|
||||||
|
func engine_rejectsAllInvalid() {
|
||||||
|
// This tests the isValid filter in applyPreferenceFilters
|
||||||
|
// We can't easily inject invalid options, but we verify the code path exists
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
)
|
||||||
|
|
||||||
|
// No games → should fail (not return empty success)
|
||||||
|
let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:])
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
#expect(!result.isSuccess, "No games should produce failure, not empty success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer 4: End-to-End Scenario Tests
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: Scenario A (Date Range)")
|
||||||
|
struct TravelIntegrity_ScenarioATests {
|
||||||
|
|
||||||
|
@Test("ScenarioA: all returned options have N-1 segments")
|
||||||
|
func scenarioA_allOptionsValid() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let games = ["New York", "Boston", "Philadelphia"].enumerated().map { i, city in
|
||||||
|
TestFixtures.game(
|
||||||
|
city: city,
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums)
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
assertAllOptionsHaveValidTravel(result, scenario: "A")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: Scenario B (Selected Games)")
|
||||||
|
struct TravelIntegrity_ScenarioBTests {
|
||||||
|
|
||||||
|
@Test("ScenarioB: all returned options have N-1 segments")
|
||||||
|
func scenarioB_allOptionsValid() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let game1 = TestFixtures.game(id: "must_see_1", city: "New York", dateTime: baseDate)
|
||||||
|
let game2 = TestFixtures.game(
|
||||||
|
id: "must_see_2", city: "Boston",
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
)
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .gameFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
mustSeeGameIds: ["must_see_1", "must_see_2"],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [game1, game2],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
assertAllOptionsHaveValidTravel(result, scenario: "B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: Scenario C (Start/End Locations)")
|
||||||
|
struct TravelIntegrity_ScenarioCTests {
|
||||||
|
|
||||||
|
@Test("ScenarioC: all returned options have N-1 segments including endpoint stops")
|
||||||
|
func scenarioC_allOptionsValid() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
|
||||||
|
let chicagoCoord = TestFixtures.coordinates["Chicago"]!
|
||||||
|
let nycCoord = TestFixtures.coordinates["New York"]!
|
||||||
|
|
||||||
|
// Games along the Chicago → NYC route
|
||||||
|
let game1 = TestFixtures.game(
|
||||||
|
city: "Detroit",
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
)
|
||||||
|
let game2 = TestFixtures.game(
|
||||||
|
city: "Philadelphia",
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
|
||||||
|
)
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .locations,
|
||||||
|
startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord),
|
||||||
|
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [game1, game2],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
assertAllOptionsHaveValidTravel(result, scenario: "C")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: Scenario D (Follow Team)")
|
||||||
|
struct TravelIntegrity_ScenarioDTests {
|
||||||
|
|
||||||
|
@Test("ScenarioD: all returned options have N-1 segments")
|
||||||
|
func scenarioD_allOptionsValid() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let teamId = "team_mlb_new_york"
|
||||||
|
|
||||||
|
let game1 = TestFixtures.game(
|
||||||
|
city: "New York", dateTime: baseDate,
|
||||||
|
homeTeamId: teamId, stadiumId: "stadium_mlb_new_york"
|
||||||
|
)
|
||||||
|
let game2 = TestFixtures.game(
|
||||||
|
city: "Boston",
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!,
|
||||||
|
homeTeamId: "team_mlb_boston",
|
||||||
|
awayTeamId: teamId,
|
||||||
|
stadiumId: "stadium_mlb_boston"
|
||||||
|
)
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
|
||||||
|
|
||||||
|
let team = TestFixtures.team(id: teamId, name: "Yankees", sport: .mlb, city: "New York")
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .followTeam,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!,
|
||||||
|
followTeamId: teamId
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [game1, game2],
|
||||||
|
teams: [teamId: team],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
assertAllOptionsHaveValidTravel(result, scenario: "D")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: Scenario E (Team-First)")
|
||||||
|
struct TravelIntegrity_ScenarioETests {
|
||||||
|
|
||||||
|
@Test("ScenarioE: all returned options have N-1 segments")
|
||||||
|
func scenarioE_allOptionsValid() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
|
||||||
|
let nycTeam = TestFixtures.team(id: "team_nyc", name: "NYC Team", sport: .mlb, city: "New York")
|
||||||
|
let bosTeam = TestFixtures.team(id: "team_bos", name: "BOS Team", sport: .mlb, city: "Boston")
|
||||||
|
|
||||||
|
// Create home games for each team
|
||||||
|
let nycGames = (0..<3).map { i in
|
||||||
|
TestFixtures.game(
|
||||||
|
id: "nyc_\(i)", city: "New York",
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: i * 2, to: baseDate)!,
|
||||||
|
homeTeamId: "team_nyc", stadiumId: "stadium_mlb_new_york"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let bosGames = (0..<3).map { i in
|
||||||
|
TestFixtures.game(
|
||||||
|
id: "bos_\(i)", city: "Boston",
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: i * 2 + 1, to: baseDate)!,
|
||||||
|
homeTeamId: "team_bos", stadiumId: "stadium_mlb_boston"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let allGames = nycGames + bosGames
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: allGames)
|
||||||
|
let teams: [String: Team] = ["team_nyc": nycTeam, "team_bos": bosTeam]
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
selectedTeamIds: ["team_nyc", "team_bos"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: allGames,
|
||||||
|
teams: teams,
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
assertAllOptionsHaveValidTravel(result, scenario: "E")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer 5: Edge Cases
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: Edge Cases")
|
||||||
|
struct TravelIntegrity_EdgeCaseTests {
|
||||||
|
|
||||||
|
@Test("Same-city consecutive stops have zero-distance segment")
|
||||||
|
func sameCityStops_haveZeroDistanceSegment() {
|
||||||
|
let coord = TestFixtures.coordinates["New York"]!
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "New York", coord: coord, day: 0),
|
||||||
|
makeStop(city: "New York", coord: coord, day: 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(result != nil, "Same-city stops should build")
|
||||||
|
#expect(result!.travelSegments.count == 1, "Must still have segment")
|
||||||
|
#expect(result!.travelSegments[0].estimatedDistanceMiles < 1, "Same-city distance should be near zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cross-country trip (NYC→LA) rejected with 1 driver, feasible with 2")
|
||||||
|
func crossCountry_feasibilityDependsOnDrivers() {
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
|
||||||
|
makeStop(city: "Los Angeles", coord: TestFixtures.coordinates["Los Angeles"]!, day: 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
// 1 driver, 8 hrs/day → max 40 hrs (5x limit). NYC→LA is ~53 hrs → infeasible
|
||||||
|
let oneDriver = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(oneDriver == nil, "NYC→LA exceeds 5x daily limit for 1 driver")
|
||||||
|
|
||||||
|
// 2 drivers, 8 hrs each → 16 hrs/day → max 80 hrs → feasible
|
||||||
|
let twoDriverConstraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
let twoDrivers = ItineraryBuilder.build(stops: stops, constraints: twoDriverConstraints)
|
||||||
|
#expect(twoDrivers != nil, "NYC→LA should build with 2 drivers")
|
||||||
|
if let built = twoDrivers {
|
||||||
|
#expect(built.travelSegments.count == 1)
|
||||||
|
#expect(built.travelSegments[0].estimatedDistanceMiles > 2000,
|
||||||
|
"NYC→LA should be 2000+ miles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Multi-stop trip never has mismatched segment count")
|
||||||
|
func multiStopTrip_neverMismatched() {
|
||||||
|
// Property test: for any number of stops 2-10, segments == stops - 1
|
||||||
|
let allCities = ["New York", "Boston", "Philadelphia", "Chicago", "Detroit",
|
||||||
|
"Atlanta", "Miami", "Houston", "Denver", "Minneapolis"]
|
||||||
|
|
||||||
|
for stopCount in 2...min(10, allCities.count) {
|
||||||
|
let cities = Array(allCities.prefix(stopCount))
|
||||||
|
let stops = cities.enumerated().map { i, city in
|
||||||
|
makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i)
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
if let built = result {
|
||||||
|
#expect(built.travelSegments.count == stopCount - 1,
|
||||||
|
"\(stopCount) stops must produce \(stopCount - 1) segments, got \(built.travelSegments.count)")
|
||||||
|
}
|
||||||
|
// nil is acceptable (infeasible), but never partial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every travel segment has positive distance when cities differ")
|
||||||
|
func everySegment_hasPositiveDistance() {
|
||||||
|
let cities = ["New York", "Boston", "Philadelphia", "Chicago"]
|
||||||
|
let stops = cities.enumerated().map { i, city in
|
||||||
|
makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i)
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
|
||||||
|
#expect(result != nil)
|
||||||
|
|
||||||
|
for (i, segment) in result!.travelSegments.enumerated() {
|
||||||
|
#expect(segment.estimatedDistanceMiles > 0,
|
||||||
|
"Segment \(i) (\(segment.fromLocation.name)→\(segment.toLocation.name)) must have positive distance")
|
||||||
|
#expect(segment.estimatedDrivingHours > 0,
|
||||||
|
"Segment \(i) must have positive driving hours")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Segment from/to locations match adjacent stops")
|
||||||
|
func segmentEndpoints_matchAdjacentStops() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
let cities = ["New York", "Boston", "Philadelphia"]
|
||||||
|
let games = cities.enumerated().map { i, city in
|
||||||
|
TestFixtures.game(
|
||||||
|
city: city,
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
for option in options {
|
||||||
|
for i in 0..<option.travelSegments.count {
|
||||||
|
let segment = option.travelSegments[i]
|
||||||
|
let fromStop = option.stops[i]
|
||||||
|
let toStop = option.stops[i + 1]
|
||||||
|
|
||||||
|
// Segment endpoints should match stop cities
|
||||||
|
#expect(segment.fromLocation.name == fromStop.city,
|
||||||
|
"Segment \(i) from '\(segment.fromLocation.name)' should match stop '\(fromStop.city)'")
|
||||||
|
#expect(segment.toLocation.name == toStop.city,
|
||||||
|
"Segment \(i) to '\(segment.toLocation.name)' should match stop '\(toStop.city)'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stress Tests
|
||||||
|
|
||||||
|
@Suite("Travel Integrity: Stress Tests")
|
||||||
|
struct TravelIntegrity_StressTests {
|
||||||
|
|
||||||
|
@Test("Large game set: all options still have valid travel")
|
||||||
|
func largeGameSet_allOptionsValid() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
|
||||||
|
// 15 games across 5 cities over 2 weeks
|
||||||
|
let cities = ["New York", "Boston", "Philadelphia", "Chicago", "Atlanta"]
|
||||||
|
var games: [Game] = []
|
||||||
|
for i in 0..<15 {
|
||||||
|
let city = cities[i % cities.count]
|
||||||
|
let date = TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
|
||||||
|
games.append(TestFixtures.game(city: city, dateTime: date))
|
||||||
|
}
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 15, to: baseDate)!
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
assertAllOptionsHaveValidTravel(result, scenario: "A-large")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("All scenarios with allowRepeatCities=false still have valid travel")
|
||||||
|
func noRepeatCities_stillValidTravel() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
|
||||||
|
let games = ["New York", "Boston", "Philadelphia", "Chicago"].enumerated().map { i, city in
|
||||||
|
TestFixtures.game(
|
||||||
|
city: city,
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!,
|
||||||
|
allowRepeatCities: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
assertAllOptionsHaveValidTravel(result, scenario: "A-noRepeat")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Scenario with must-stop constraint still has valid travel")
|
||||||
|
func mustStop_stillValidTravel() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
|
||||||
|
|
||||||
|
let games = ["New York", "Boston", "Philadelphia"].enumerated().map { i, city in
|
||||||
|
TestFixtures.game(
|
||||||
|
city: city,
|
||||||
|
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!,
|
||||||
|
mustStopLocations: [LocationInput(name: "Boston")]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
assertAllOptionsHaveValidTravel(result, scenario: "A-mustStop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test Helpers
|
||||||
|
|
||||||
|
/// Asserts that ALL options in a result have valid travel segments.
|
||||||
|
/// This is THE critical assertion for this test file.
|
||||||
|
private func assertAllOptionsHaveValidTravel(
|
||||||
|
_ result: ItineraryResult,
|
||||||
|
scenario: String,
|
||||||
|
sourceLocation: SourceLocation = #_sourceLocation
|
||||||
|
) {
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
// Failure is OK — means engine couldn't find valid routes
|
||||||
|
// What's NOT OK is returning invalid success
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!options.isEmpty, "Scenario \(scenario): success should have options",
|
||||||
|
sourceLocation: sourceLocation)
|
||||||
|
|
||||||
|
for (i, option) in options.enumerated() {
|
||||||
|
// THE CRITICAL CHECK
|
||||||
|
#expect(option.isValid,
|
||||||
|
"Scenario \(scenario) option \(i): \(option.stops.count) stops must have \(max(0, option.stops.count - 1)) segments, got \(option.travelSegments.count)",
|
||||||
|
sourceLocation: sourceLocation)
|
||||||
|
|
||||||
|
// Additional checks
|
||||||
|
if option.stops.count > 1 {
|
||||||
|
#expect(option.travelSegments.count == option.stops.count - 1,
|
||||||
|
"Scenario \(scenario) option \(i): segment count mismatch",
|
||||||
|
sourceLocation: sourceLocation)
|
||||||
|
|
||||||
|
// Every segment must have non-negative distance
|
||||||
|
for (j, seg) in option.travelSegments.enumerated() {
|
||||||
|
#expect(seg.estimatedDistanceMiles >= 0,
|
||||||
|
"Scenario \(scenario) option \(i) segment \(j): negative distance",
|
||||||
|
sourceLocation: sourceLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to create a basic ItineraryStop for testing.
|
||||||
|
private func makeStop(
|
||||||
|
city: String,
|
||||||
|
coord: CLLocationCoordinate2D?,
|
||||||
|
day: Int
|
||||||
|
) -> ItineraryStop {
|
||||||
|
let date = TestClock.addingDays(day)
|
||||||
|
return ItineraryStop(
|
||||||
|
city: city,
|
||||||
|
state: TestFixtures.states[city] ?? "",
|
||||||
|
coordinate: coord,
|
||||||
|
games: ["game_\(city.lowercased())_\(day)"],
|
||||||
|
arrivalDate: date,
|
||||||
|
departureDate: date,
|
||||||
|
location: LocationInput(name: city, coordinate: coord),
|
||||||
|
firstGameStart: date
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,48 +20,6 @@ struct TripPlanningEngineTests {
|
|||||||
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
|
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
|
||||||
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
|
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
|
||||||
|
|
||||||
// MARK: - Specification Tests: Planning Mode Selection
|
|
||||||
|
|
||||||
@Test("planningMode: dateRange is valid mode")
|
|
||||||
func planningMode_dateRange() {
|
|
||||||
let prefs = TripPreferences(
|
|
||||||
planningMode: .dateRange,
|
|
||||||
sports: [.mlb]
|
|
||||||
)
|
|
||||||
#expect(prefs.planningMode == .dateRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("planningMode: gameFirst is valid mode")
|
|
||||||
func planningMode_gameFirst() {
|
|
||||||
let prefs = TripPreferences(
|
|
||||||
planningMode: .gameFirst,
|
|
||||||
sports: [.mlb],
|
|
||||||
mustSeeGameIds: ["game1"]
|
|
||||||
)
|
|
||||||
#expect(prefs.planningMode == .gameFirst)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("planningMode: followTeam is valid mode")
|
|
||||||
func planningMode_followTeam() {
|
|
||||||
let prefs = TripPreferences(
|
|
||||||
planningMode: .followTeam,
|
|
||||||
sports: [.mlb],
|
|
||||||
followTeamId: "yankees"
|
|
||||||
)
|
|
||||||
#expect(prefs.planningMode == .followTeam)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("planningMode: locations is valid mode")
|
|
||||||
func planningMode_locations() {
|
|
||||||
let prefs = TripPreferences(
|
|
||||||
planningMode: .locations,
|
|
||||||
startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord),
|
|
||||||
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
|
|
||||||
sports: [.mlb]
|
|
||||||
)
|
|
||||||
#expect(prefs.planningMode == .locations)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Specification Tests: Driving Constraints
|
// MARK: - Specification Tests: Driving Constraints
|
||||||
|
|
||||||
@Test("DrivingConstraints: calculates maxDailyDrivingHours correctly")
|
@Test("DrivingConstraints: calculates maxDailyDrivingHours correctly")
|
||||||
@@ -74,7 +32,7 @@ struct TripPlanningEngineTests {
|
|||||||
func drivingConstraints_clampsNegativeDrivers() {
|
func drivingConstraints_clampsNegativeDrivers() {
|
||||||
let constraints = DrivingConstraints(numberOfDrivers: -5, maxHoursPerDriverPerDay: 8.0)
|
let constraints = DrivingConstraints(numberOfDrivers: -5, maxHoursPerDriverPerDay: 8.0)
|
||||||
#expect(constraints.numberOfDrivers == 1)
|
#expect(constraints.numberOfDrivers == 1)
|
||||||
#expect(constraints.maxDailyDrivingHours >= 1.0)
|
#expect(constraints.maxDailyDrivingHours == 8.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("DrivingConstraints: clamps zero hours to minimum")
|
@Test("DrivingConstraints: clamps zero hours to minimum")
|
||||||
@@ -131,9 +89,11 @@ struct TripPlanningEngineTests {
|
|||||||
func invariant_totalDriverHoursPositive() {
|
func invariant_totalDriverHoursPositive() {
|
||||||
let prefs1 = TripPreferences(numberOfDrivers: 1)
|
let prefs1 = TripPreferences(numberOfDrivers: 1)
|
||||||
#expect(prefs1.totalDriverHoursPerDay > 0)
|
#expect(prefs1.totalDriverHoursPerDay > 0)
|
||||||
|
#expect(prefs1.totalDriverHoursPerDay == 8.0) // 1 driver × 8 hrs
|
||||||
|
|
||||||
let prefs2 = TripPreferences(numberOfDrivers: 3, maxDrivingHoursPerDriver: 4)
|
let prefs2 = TripPreferences(numberOfDrivers: 3, maxDrivingHoursPerDriver: 4)
|
||||||
#expect(prefs2.totalDriverHoursPerDay > 0)
|
#expect(prefs2.totalDriverHoursPerDay > 0)
|
||||||
|
#expect(prefs2.totalDriverHoursPerDay == 12.0) // 3 drivers × 4 hrs
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Invariant: effectiveTripDuration >= 1")
|
@Test("Invariant: effectiveTripDuration >= 1")
|
||||||
@@ -144,6 +104,161 @@ struct TripPlanningEngineTests {
|
|||||||
let prefs = TripPreferences(tripDuration: duration)
|
let prefs = TripPreferences(tripDuration: duration)
|
||||||
#expect(prefs.effectiveTripDuration >= 1)
|
#expect(prefs.effectiveTripDuration >= 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify specific value for nil duration with default dates
|
||||||
|
let prefsNil = TripPreferences(tripDuration: nil)
|
||||||
|
#expect(prefsNil.effectiveTripDuration == 8) // Default 7-day range = 8 days inclusive
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Travel Segment Validation
|
||||||
|
|
||||||
|
@Test("planTrip: multi-stop result always has travel segments")
|
||||||
|
func planTrip_multiStopResult_alwaysHasTravelSegments() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
||||||
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
||||||
|
|
||||||
|
let game1 = TestFixtures.game(city: "New York", dateTime: baseDate)
|
||||||
|
let game2 = TestFixtures.game(city: "Boston", dateTime: day2)
|
||||||
|
let game3 = TestFixtures.game(city: "Philadelphia", dateTime: day3)
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2, game3])
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: day3
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [game1, game2, game3],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
for option in options {
|
||||||
|
#expect(option.isValid, "Every returned option must be valid (segments = stops - 1)")
|
||||||
|
if option.stops.count > 1 {
|
||||||
|
#expect(option.travelSegments.count == option.stops.count - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("planTrip: N stops always have exactly N-1 travel segments")
|
||||||
|
func planTrip_nStops_haveExactlyNMinus1Segments() {
|
||||||
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
||||||
|
|
||||||
|
// Create 5 games across cities to produce routes of varying lengths
|
||||||
|
let cities = ["New York", "Boston", "Philadelphia", "Chicago", "Detroit"]
|
||||||
|
var games: [Game] = []
|
||||||
|
for (i, city) in cities.enumerated() {
|
||||||
|
let date = TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
|
||||||
|
games.append(TestFixtures.game(city: city, dateTime: date))
|
||||||
|
}
|
||||||
|
|
||||||
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
||||||
|
let endDate = TestClock.calendar.date(byAdding: .day, value: cities.count, to: baseDate)!
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: baseDate,
|
||||||
|
endDate: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: games,
|
||||||
|
teams: [:],
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
||||||
|
#expect(!options.isEmpty, "Should produce at least one option")
|
||||||
|
for option in options {
|
||||||
|
if option.stops.count > 1 {
|
||||||
|
#expect(option.travelSegments.count == option.stops.count - 1,
|
||||||
|
"Option with \(option.stops.count) stops must have exactly \(option.stops.count - 1) segments, got \(option.travelSegments.count)")
|
||||||
|
} else {
|
||||||
|
#expect(option.travelSegments.isEmpty,
|
||||||
|
"Single-stop option must have 0 segments")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ItineraryOption.isValid: correctly validates segment count")
|
||||||
|
func planTrip_invalidOptions_areFilteredOut() {
|
||||||
|
// Create a valid ItineraryOption manually with wrong segment count
|
||||||
|
let stop1 = ItineraryStop(
|
||||||
|
city: "New York", state: "NY",
|
||||||
|
coordinate: nycCoord,
|
||||||
|
games: ["g1"], arrivalDate: Date(), departureDate: Date(),
|
||||||
|
location: LocationInput(name: "New York", coordinate: nycCoord),
|
||||||
|
firstGameStart: Date()
|
||||||
|
)
|
||||||
|
let stop2 = ItineraryStop(
|
||||||
|
city: "Boston", state: "MA",
|
||||||
|
coordinate: bostonCoord,
|
||||||
|
games: ["g2"], arrivalDate: Date(), departureDate: Date(),
|
||||||
|
location: LocationInput(name: "Boston", coordinate: bostonCoord),
|
||||||
|
firstGameStart: Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invalid: 2 stops but 0 segments
|
||||||
|
let invalidOption = ItineraryOption(
|
||||||
|
rank: 1, stops: [stop1, stop2],
|
||||||
|
travelSegments: [],
|
||||||
|
totalDrivingHours: 0, totalDistanceMiles: 0,
|
||||||
|
geographicRationale: "test"
|
||||||
|
)
|
||||||
|
#expect(!invalidOption.isValid, "2 stops with 0 segments should be invalid")
|
||||||
|
|
||||||
|
// Valid: 2 stops with 1 segment
|
||||||
|
let segment = TestFixtures.travelSegment(from: "New York", to: "Boston")
|
||||||
|
let validOption = ItineraryOption(
|
||||||
|
rank: 1, stops: [stop1, stop2],
|
||||||
|
travelSegments: [segment],
|
||||||
|
totalDrivingHours: 3.5, totalDistanceMiles: 215,
|
||||||
|
geographicRationale: "test"
|
||||||
|
)
|
||||||
|
#expect(validOption.isValid, "2 stops with 1 segment should be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("planTrip: inverted date range returns failure")
|
||||||
|
func planTrip_invertedDateRange_returnsFailure() {
|
||||||
|
let endDate = TestFixtures.date(year: 2026, month: 6, day: 1)
|
||||||
|
let startDate = TestFixtures.date(year: 2026, month: 6, day: 10)
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
sports: [.mlb],
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: [],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: [:]
|
||||||
|
)
|
||||||
|
|
||||||
|
let engine = TripPlanningEngine()
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
#expect(!result.isSuccess)
|
||||||
|
if let failure = result.failure {
|
||||||
|
#expect(failure.reason == .missingDateRange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|||||||
@@ -250,56 +250,87 @@ struct LocationPermissionManagerPropertiesTests {
|
|||||||
// MARK: - Specification Tests: isAuthorized
|
// MARK: - Specification Tests: isAuthorized
|
||||||
|
|
||||||
/// - Expected Behavior: true when authorizedWhenInUse or authorizedAlways
|
/// - Expected Behavior: true when authorizedWhenInUse or authorizedAlways
|
||||||
|
/// Tests the isAuthorized logic: status == .authorizedWhenInUse || status == .authorizedAlways
|
||||||
@Test("isAuthorized: logic based on CLAuthorizationStatus")
|
@Test("isAuthorized: logic based on CLAuthorizationStatus")
|
||||||
func isAuthorized_logic() {
|
func isAuthorized_logic() {
|
||||||
// This tests the expected behavior definition
|
// Mirror the production logic from LocationPermissionManager.isAuthorized
|
||||||
// Actual test would require mocking CLAuthorizationStatus
|
func isAuthorized(_ status: CLAuthorizationStatus) -> Bool {
|
||||||
|
status == .authorizedWhenInUse || status == .authorizedAlways
|
||||||
|
}
|
||||||
|
|
||||||
// authorizedWhenInUse should be authorized
|
#expect(isAuthorized(.authorizedWhenInUse) == true)
|
||||||
// authorizedAlways should be authorized
|
#expect(isAuthorized(.authorizedAlways) == true)
|
||||||
// notDetermined should NOT be authorized
|
#expect(isAuthorized(.notDetermined) == false)
|
||||||
// denied should NOT be authorized
|
#expect(isAuthorized(.denied) == false)
|
||||||
// restricted should NOT be authorized
|
#expect(isAuthorized(.restricted) == false)
|
||||||
|
|
||||||
// We verify the logic by checking the definition
|
|
||||||
#expect(true) // Placeholder - actual implementation uses CLAuthorizationStatus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: needsPermission
|
// MARK: - Specification Tests: needsPermission
|
||||||
|
|
||||||
/// - Expected Behavior: true only when notDetermined
|
/// - Expected Behavior: true only when notDetermined
|
||||||
|
/// Tests the needsPermission logic: status == .notDetermined
|
||||||
@Test("needsPermission: true only when notDetermined")
|
@Test("needsPermission: true only when notDetermined")
|
||||||
func needsPermission_logic() {
|
func needsPermission_logic() {
|
||||||
// notDetermined should need permission
|
func needsPermission(_ status: CLAuthorizationStatus) -> Bool {
|
||||||
// denied should NOT need permission (already determined)
|
status == .notDetermined
|
||||||
// authorized should NOT need permission
|
}
|
||||||
|
|
||||||
#expect(true) // Placeholder - actual implementation uses CLAuthorizationStatus
|
#expect(needsPermission(.notDetermined) == true)
|
||||||
|
#expect(needsPermission(.denied) == false)
|
||||||
|
#expect(needsPermission(.restricted) == false)
|
||||||
|
#expect(needsPermission(.authorizedWhenInUse) == false)
|
||||||
|
#expect(needsPermission(.authorizedAlways) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: isDenied
|
// MARK: - Specification Tests: isDenied
|
||||||
|
|
||||||
/// - Expected Behavior: true when denied or restricted
|
/// - Expected Behavior: true when denied or restricted
|
||||||
|
/// Tests the isDenied logic: status == .denied || status == .restricted
|
||||||
@Test("isDenied: true when denied or restricted")
|
@Test("isDenied: true when denied or restricted")
|
||||||
func isDenied_logic() {
|
func isDenied_logic() {
|
||||||
// denied should be isDenied
|
func isDenied(_ status: CLAuthorizationStatus) -> Bool {
|
||||||
// restricted should be isDenied
|
status == .denied || status == .restricted
|
||||||
// notDetermined should NOT be isDenied
|
}
|
||||||
// authorized should NOT be isDenied
|
|
||||||
|
|
||||||
#expect(true) // Placeholder - actual implementation uses CLAuthorizationStatus
|
#expect(isDenied(.denied) == true)
|
||||||
|
#expect(isDenied(.restricted) == true)
|
||||||
|
#expect(isDenied(.notDetermined) == false)
|
||||||
|
#expect(isDenied(.authorizedWhenInUse) == false)
|
||||||
|
#expect(isDenied(.authorizedAlways) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: statusMessage
|
// MARK: - Specification Tests: statusMessage
|
||||||
|
|
||||||
/// - Expected Behavior: Each status has a user-friendly message
|
/// - Expected Behavior: Each status has a user-friendly message
|
||||||
|
/// Tests the statusMessage logic: every CLAuthorizationStatus maps to a non-empty string
|
||||||
@Test("statusMessage: all statuses have messages")
|
@Test("statusMessage: all statuses have messages")
|
||||||
func statusMessage_allHaveMessages() {
|
func statusMessage_allHaveMessages() {
|
||||||
// notDetermined: explains location helps find stadiums
|
func statusMessage(_ status: CLAuthorizationStatus) -> String {
|
||||||
// restricted: explains access is restricted
|
switch status {
|
||||||
// denied: explains how to enable in Settings
|
case .notDetermined:
|
||||||
// authorized: confirms access granted
|
return "Location access helps find nearby stadiums and optimize your route."
|
||||||
|
case .restricted:
|
||||||
|
return "Location access is restricted on this device."
|
||||||
|
case .denied:
|
||||||
|
return "Location access was denied. Enable it in Settings to use this feature."
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
|
return "Location access granted."
|
||||||
|
@unknown default:
|
||||||
|
return "Unknown location status."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#expect(true) // Placeholder - actual implementation uses CLAuthorizationStatus
|
let allStatuses: [CLAuthorizationStatus] = [
|
||||||
|
.notDetermined, .restricted, .denied, .authorizedWhenInUse, .authorizedAlways
|
||||||
|
]
|
||||||
|
|
||||||
|
for status in allStatuses {
|
||||||
|
let message = statusMessage(status)
|
||||||
|
#expect(!message.isEmpty, "Status \(status.rawValue) should have a non-empty message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify distinct messages for distinct status categories
|
||||||
|
let messages = Set(allStatuses.map { statusMessage($0) })
|
||||||
|
#expect(messages.count >= 4, "Should have at least 4 distinct messages")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,11 +118,15 @@ struct CacheStatsTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// - Invariant: sum of entriesBySport <= totalEntries
|
/// - Invariant: sum of entriesBySport <= totalEntries
|
||||||
|
// NOTE: CacheStats is a plain data struct — this test documents the expected
|
||||||
|
// relationship between sport entries and total, not enforcement by the cache.
|
||||||
|
// The struct does not validate or clamp values; callers are responsible for
|
||||||
|
// providing consistent data.
|
||||||
@Test("Invariant: sport entries sum does not exceed total")
|
@Test("Invariant: sport entries sum does not exceed total")
|
||||||
func invariant_sportEntriesSumDoesNotExceedTotal() {
|
func invariant_sportEntriesSumDoesNotExceedTotal() {
|
||||||
let bySport: [Sport: Int] = [.mlb: 30, .nba: 40, .nhl: 30]
|
let bySport: [Sport: Int] = [.mlb: 30, .nba: 40, .nhl: 30]
|
||||||
let stats = makeStats(totalEntries: 100, entriesBySport: bySport)
|
let stats = makeStats(totalEntries: 100, entriesBySport: bySport)
|
||||||
let sportSum = bySport.values.reduce(0, +)
|
let sportSum = stats.entriesBySport.values.reduce(0, +)
|
||||||
#expect(sportSum <= stats.totalEntries)
|
#expect(sportSum <= stats.totalEntries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,33 @@ class BaseUITestCase: XCTestCase {
|
|||||||
screenshot.lifetime = .keepAlways
|
screenshot.lifetime = .keepAlways
|
||||||
add(screenshot)
|
add(screenshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Polls until the condition becomes true or the timeout expires.
|
||||||
|
@discardableResult
|
||||||
|
func waitUntil(
|
||||||
|
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
|
||||||
|
pollInterval: TimeInterval = 0.2,
|
||||||
|
_ message: String? = nil,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line,
|
||||||
|
condition: @escaping () -> Bool
|
||||||
|
) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|
||||||
|
while Date() < deadline {
|
||||||
|
if condition() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = deadline.timeIntervalSinceNow
|
||||||
|
let interval = min(pollInterval, max(0.01, remaining))
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(interval))
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = condition()
|
||||||
|
XCTAssertTrue(success, message ?? "Condition was not met within \(timeout)s", file: file, line: line)
|
||||||
|
return success
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Wait Helpers
|
// MARK: - Wait Helpers
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ final class AppLaunchTests: BaseUITestCase {
|
|||||||
|
|
||||||
// Background the app
|
// Background the app
|
||||||
XCUIDevice.shared.press(.home)
|
XCUIDevice.shared.press(.home)
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Foreground
|
// Foreground
|
||||||
app.activate()
|
app.activate()
|
||||||
|
waitUntil(timeout: BaseUITestCase.longTimeout, "App should return to the foreground") {
|
||||||
|
self.app.state == .runningForeground
|
||||||
|
}
|
||||||
|
|
||||||
// Assert: Home still loaded, no re-bootstrap
|
// Assert: Home still loaded, no re-bootstrap
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
|
|||||||
@@ -85,12 +85,10 @@ final class HomeTests: BaseUITestCase {
|
|||||||
// Tap refresh and verify no crash
|
// Tap refresh and verify no crash
|
||||||
refreshButton.tap()
|
refreshButton.tap()
|
||||||
|
|
||||||
// Wait briefly for reload
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Featured section should still exist after refresh
|
// Featured section should still exist after refresh
|
||||||
XCTAssertTrue(section.exists,
|
waitUntil(timeout: BaseUITestCase.longTimeout, "Featured trips section should remain after refresh") {
|
||||||
"Featured trips section should remain after refresh")
|
section.exists && refreshButton.exists
|
||||||
|
}
|
||||||
|
|
||||||
captureScreenshot(named: "F015-FeaturedTripsRefresh")
|
captureScreenshot(named: "F015-FeaturedTripsRefresh")
|
||||||
}
|
}
|
||||||
@@ -307,14 +305,17 @@ final class HomeTests: BaseUITestCase {
|
|||||||
home.waitForLoad()
|
home.waitForLoad()
|
||||||
home.switchToTab(home.myTripsTab)
|
home.switchToTab(home.myTripsTab)
|
||||||
|
|
||||||
// Wait briefly for My Trips content to load
|
let myTrips = MyTripsScreen(app: app)
|
||||||
sleep(1)
|
waitUntil(timeout: BaseUITestCase.longTimeout, "My Trips should load before refreshing") {
|
||||||
|
myTrips.emptyState.exists || myTrips.tripCard(0).exists || self.app.staticTexts["Group Polls"].exists
|
||||||
|
}
|
||||||
|
|
||||||
// Pull down to refresh
|
// Pull down to refresh
|
||||||
app.swipeDown(velocity: .slow)
|
app.swipeDown(velocity: .slow)
|
||||||
|
|
||||||
// Wait for any refresh to complete
|
waitUntil(timeout: BaseUITestCase.longTimeout, "My Trips should still be loaded after pull to refresh") {
|
||||||
sleep(2)
|
myTrips.emptyState.exists || myTrips.tripCard(0).exists || self.app.staticTexts["Group Polls"].exists
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the tab is still functional (no crash)
|
// Verify the tab is still functional (no crash)
|
||||||
let groupPolls = app.staticTexts["Group Polls"]
|
let groupPolls = app.staticTexts["Group Polls"]
|
||||||
|
|||||||
@@ -206,22 +206,14 @@ final class ProgressTests: BaseUITestCase {
|
|||||||
visitSheet.tapSave()
|
visitSheet.tapSave()
|
||||||
visitSheet.navigationBar.waitForNonExistence(timeout: BaseUITestCase.defaultTimeout)
|
visitSheet.navigationBar.waitForNonExistence(timeout: BaseUITestCase.defaultTimeout)
|
||||||
|
|
||||||
// Wait for data to reload
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Progress should have updated — verify the progress circle label changed
|
// Progress should have updated — verify the progress circle label changed
|
||||||
let updatedCircle = app.descendants(matching: .any).matching(NSPredicate(
|
let updatedCircle = app.descendants(matching: .any).matching(NSPredicate(
|
||||||
format: "label CONTAINS 'stadiums visited'"
|
format: "label CONTAINS 'stadiums visited'"
|
||||||
)).firstMatch
|
)).firstMatch
|
||||||
|
|
||||||
XCTAssertTrue(updatedCircle.waitForExistence(timeout: BaseUITestCase.longTimeout),
|
waitUntil(timeout: BaseUITestCase.longTimeout, "Progress label should update after adding a visit") {
|
||||||
"Progress circle should exist after adding a visit")
|
guard updatedCircle.exists else { return false }
|
||||||
|
return initialLabel.isEmpty || updatedCircle.label != initialLabel
|
||||||
// If we had an initial label, verify it changed
|
|
||||||
if !initialLabel.isEmpty {
|
|
||||||
// The new label should have a higher visited count
|
|
||||||
XCTAssertNotEqual(updatedCircle.label, initialLabel,
|
|
||||||
"Progress label should update after adding a visit")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
captureScreenshot(named: "F099-ProgressPercentageUpdated")
|
captureScreenshot(named: "F099-ProgressPercentageUpdated")
|
||||||
|
|||||||
@@ -127,9 +127,10 @@ final class ScheduleTests: BaseUITestCase {
|
|||||||
"Search field should exist")
|
"Search field should exist")
|
||||||
searchField.tap()
|
searchField.tap()
|
||||||
searchField.typeText("Yankees")
|
searchField.typeText("Yankees")
|
||||||
|
XCTAssertTrue(
|
||||||
// Wait for results to filter
|
((searchField.value as? String) ?? "").contains("Yankees"),
|
||||||
sleep(1)
|
"Search field should contain the typed team name"
|
||||||
|
)
|
||||||
|
|
||||||
captureScreenshot(named: "F089-SearchByTeam")
|
captureScreenshot(named: "F089-SearchByTeam")
|
||||||
}
|
}
|
||||||
@@ -150,9 +151,10 @@ final class ScheduleTests: BaseUITestCase {
|
|||||||
"Search field should exist")
|
"Search field should exist")
|
||||||
searchField.tap()
|
searchField.tap()
|
||||||
searchField.typeText("Wrigley")
|
searchField.typeText("Wrigley")
|
||||||
|
XCTAssertTrue(
|
||||||
// Wait for results to filter
|
((searchField.value as? String) ?? "").contains("Wrigley"),
|
||||||
sleep(1)
|
"Search field should contain the typed venue name"
|
||||||
|
)
|
||||||
|
|
||||||
captureScreenshot(named: "F090-SearchByVenue")
|
captureScreenshot(named: "F090-SearchByVenue")
|
||||||
}
|
}
|
||||||
@@ -174,19 +176,15 @@ final class ScheduleTests: BaseUITestCase {
|
|||||||
searchField.tap()
|
searchField.tap()
|
||||||
searchField.typeText("ZZZZNONEXISTENTTEAMZZZZ")
|
searchField.typeText("ZZZZNONEXISTENTTEAMZZZZ")
|
||||||
|
|
||||||
// Wait for empty state
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Empty state or "no results" text should appear
|
// Empty state or "no results" text should appear
|
||||||
let emptyState = schedule.emptyState
|
let emptyState = schedule.emptyState
|
||||||
let noResults = app.staticTexts.matching(NSPredicate(
|
let noResults = app.staticTexts.matching(NSPredicate(
|
||||||
format: "label CONTAINS[c] 'no' AND label CONTAINS[c] 'game'"
|
format: "label CONTAINS[c] 'no' AND label CONTAINS[c] 'game'"
|
||||||
)).firstMatch
|
)).firstMatch
|
||||||
|
|
||||||
let hasEmptyIndicator = emptyState.waitForExistence(timeout: BaseUITestCase.shortTimeout)
|
waitUntil(timeout: BaseUITestCase.shortTimeout, "Empty state should appear when no games match search") {
|
||||||
|| noResults.waitForExistence(timeout: BaseUITestCase.shortTimeout)
|
emptyState.exists || noResults.exists
|
||||||
XCTAssertTrue(hasEmptyIndicator,
|
}
|
||||||
"Empty state should appear when no games match search")
|
|
||||||
|
|
||||||
captureScreenshot(named: "F092-ScheduleEmptyState")
|
captureScreenshot(named: "F092-ScheduleEmptyState")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,10 +162,10 @@ final class SettingsTests: BaseUITestCase {
|
|||||||
let switchCoord = toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
|
let switchCoord = toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
|
||||||
switchCoord.tap()
|
switchCoord.tap()
|
||||||
|
|
||||||
// Small wait for the toggle animation to complete
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Value should have changed
|
// Value should have changed
|
||||||
|
waitUntil(timeout: BaseUITestCase.shortTimeout, "Toggle value should change after tapping the switch") {
|
||||||
|
(toggle.value as? String) != initialValue
|
||||||
|
}
|
||||||
let newValue = toggle.value as? String
|
let newValue = toggle.value as? String
|
||||||
XCTAssertNotEqual(initialValue, newValue,
|
XCTAssertNotEqual(initialValue, newValue,
|
||||||
"Toggle value should change after tap (was '\(initialValue ?? "nil")', now '\(newValue ?? "nil")')")
|
"Toggle value should change after tap (was '\(initialValue ?? "nil")', now '\(newValue ?? "nil")')")
|
||||||
|
|||||||
@@ -157,8 +157,11 @@ final class TripOptionsTests: BaseUITestCase {
|
|||||||
"'5' cities filter button should exist")
|
"'5' cities filter button should exist")
|
||||||
fiveCitiesButton.tap()
|
fiveCitiesButton.tap()
|
||||||
|
|
||||||
// Results should update; verify no crash
|
let firstTrip = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'tripOptions.trip.'")).firstMatch
|
||||||
sleep(1)
|
XCTAssertTrue(
|
||||||
|
firstTrip.waitForExistence(timeout: BaseUITestCase.shortTimeout),
|
||||||
|
"Trip results should remain visible after applying the cities filter"
|
||||||
|
)
|
||||||
|
|
||||||
captureScreenshot(named: "F057-CitiesFilter-5")
|
captureScreenshot(named: "F057-CitiesFilter-5")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Do not add one-off test-only branching logic unless it removes a real flake.
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO \
|
-parallel-testing-enabled NO \
|
||||||
-only-testing:SportsTimeUITests/TripWizardFlowTests/testF026_DateRangeSelection
|
-only-testing:SportsTimeUITests/TripWizardFlowTests/testF026_DateRangeSelection
|
||||||
```
|
```
|
||||||
@@ -74,7 +74,7 @@ xcodebuild test-without-building \
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO \
|
-parallel-testing-enabled NO \
|
||||||
-only-testing:SportsTimeUITests/TripOptionsTests
|
-only-testing:SportsTimeUITests/TripOptionsTests
|
||||||
```
|
```
|
||||||
@@ -85,7 +85,7 @@ xcodebuild test-without-building \
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO \
|
-parallel-testing-enabled NO \
|
||||||
-only-testing:SportsTimeUITests
|
-only-testing:SportsTimeUITests
|
||||||
```
|
```
|
||||||
@@ -96,7 +96,7 @@ xcodebuild test-without-building \
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO
|
-parallel-testing-enabled NO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ If a candidate looks flaky/high-risk, skip it and explain why.
|
|||||||
|
|
||||||
Use this destination:
|
Use this destination:
|
||||||
|
|
||||||
`-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2'`
|
`-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'`
|
||||||
|
|
||||||
Run each selected test explicitly:
|
Run each selected test explicitly:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user