Merge branch 'main' of gitea.treytartt.com:admin/Sportstime

This commit is contained in:
Trey t
2026-04-16 14:34:48 -05:00
23 changed files with 265 additions and 109 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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))
} }

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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
) )

View File

@@ -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)")

View File

@@ -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 = {

View File

@@ -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

View File

@@ -531,10 +531,18 @@ 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 // MARK: - Route Preference Tests
@@ -576,7 +584,7 @@ struct GameDAGRouterTests {
let directMiles = totalMiles(for: directFirst, stadiums: stadiums) let directMiles = totalMiles(for: directFirst, stadiums: stadiums)
let scenicMiles = totalMiles(for: scenicFirst, stadiums: stadiums) let scenicMiles = totalMiles(for: scenicFirst, stadiums: stadiums)
// Direct should tend toward lower mileage routes being ranked first // Direct should tend toward lower mileage routes being ranked first
#expect(directMiles <= scenicMiles + 500, "Direct route should not be significantly longer than scenic") #expect(directMiles <= scenicMiles, "Direct first route (\(Int(directMiles))mi) should be <= scenic first route (\(Int(scenicMiles))mi)")
} }
} }
@@ -606,6 +614,16 @@ struct GameDAGRouterTests {
Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count
}.max() ?? 0 }.max() ?? 0
#expect(maxCities >= 2, "Scenic should produce multi-city routes") #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") @Test("routePreference: balanced matches default behavior")
@@ -629,6 +647,12 @@ struct GameDAGRouterTests {
// Both should produce the same routes (balanced is default) // Both should produce the same routes (balanced is default)
#expect(balancedRoutes.count == defaultRoutes.count) #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 // MARK: - Route Preference Scoring Tests

View File

@@ -63,6 +63,25 @@ struct Phase1A_RoutePreferenceTests {
#expect(route[i].startTime <= route[i + 1].startTime) #expect(route[i].startTime <= route[i + 1].startTime)
} }
} }
// Direct should produce lower-mileage first routes than scenic
if let directFirst = directRoutes.first, let scenicFirst = scenicRoutes.first {
let directMiles = directFirst.compactMap { game -> Double? in
guard let idx = directFirst.firstIndex(where: { $0.id == game.id }),
idx > 0,
let from = stadiums[directFirst[idx - 1].stadiumId],
let to = stadiums[game.stadiumId] else { return nil }
return TravelEstimator.haversineDistanceMiles(from: from.coordinate, to: to.coordinate) * 1.3
}.reduce(0, +)
let scenicMiles = scenicFirst.compactMap { game -> Double? in
guard let idx = scenicFirst.firstIndex(where: { $0.id == game.id }),
idx > 0,
let from = stadiums[scenicFirst[idx - 1].stadiumId],
let to = stadiums[game.stadiumId] else { return nil }
return TravelEstimator.haversineDistanceMiles(from: from.coordinate, to: to.coordinate) * 1.3
}.reduce(0, +)
#expect(directMiles <= scenicMiles, "Direct first route should have <= mileage than scenic first route")
}
} }
@Test("findRoutes accepts routePreference parameter for all values") @Test("findRoutes accepts routePreference parameter for all values")
@@ -165,6 +184,9 @@ struct Phase1B_ScenarioERegionTests {
let nycTeam = TestFixtures.team(id: "team_nyc", name: "NYC Team", sport: .mlb, city: "New York") 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") let bosTeam = TestFixtures.team(id: "team_bos", name: "BOS Team", sport: .mlb, city: "Boston")
// With 2 teams, teamFirstMaxDays = 4. The sliding window needs the game
// span to be wide enough so a 4-day window can contain both games.
// Space games 3 days apart so a window from June 1 to June 5 covers both.
let game1 = TestFixtures.game( let game1 = TestFixtures.game(
id: "nyc_home", city: "New York", id: "nyc_home", city: "New York",
dateTime: baseDate, dateTime: baseDate,
@@ -173,7 +195,7 @@ struct Phase1B_ScenarioERegionTests {
) )
let game2 = TestFixtures.game( let game2 = TestFixtures.game(
id: "bos_home", city: "Boston", id: "bos_home", city: "Boston",
dateTime: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!, dateTime: TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!,
homeTeamId: "team_bos", homeTeamId: "team_bos",
stadiumId: "stadium_mlb_boston" stadiumId: "stadium_mlb_boston"
) )
@@ -199,13 +221,11 @@ struct Phase1B_ScenarioERegionTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
// Should succeed with both nearby East Coast teams. // Should succeed with both nearby East Coast teams.
// Failure is also OK if driving constraints prevent it. guard case .success(let options) = result else {
switch result { Issue.record("Expected .success for NYC + Boston (nearby East Coast teams), got \(result)")
case .success(let options): return
#expect(!options.isEmpty, "Should find routes for NYC + Boston")
case .failure:
break // Acceptable driving constraints may prevent a valid route
} }
#expect(!options.isEmpty, "Should find routes for NYC + Boston")
} }
} }
@@ -588,15 +608,17 @@ struct Phase2D_ExclusionWarningTests {
// If all routes had repeat cities, failure is also acceptable // If all routes had repeat cities, failure is also acceptable
return return
} }
// Every returned route must have unique cities per calendar day
for option in options { for option in options {
let calendar = Calendar.current // Group stops by city, check no city appears on multiple calendar days
var cityDays: Set<String> = [] var cityDays: [String: Set<Date>] = [:]
let calendar = TestClock.calendar
for stop in option.stops { for stop in option.stops {
let day = calendar.startOfDay(for: stop.arrivalDate) let day = calendar.startOfDay(for: stop.arrivalDate)
let key = "\(stop.city.lowercased())_\(day.timeIntervalSince1970)" let city = stop.city.lowercased()
#expect(!cityDays.contains(key), "Route should not visit \(stop.city) on the same day twice") cityDays[city, default: []].insert(day)
cityDays.insert(key) }
for (city, days) in cityDays {
#expect(days.count <= 1, "City '\(city)' appears on \(days.count) different days — repeat city violation")
} }
} }
} }

View File

@@ -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")

View File

@@ -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)
} }

View File

@@ -703,12 +703,23 @@ struct Bug15_DateArithmeticTests {
let planner = ScenarioBPlanner() let planner = ScenarioBPlanner()
let result = planner.plan(request: request) let result = planner.plan(request: request)
// Should not crash verify we get a valid result (success or failure, not a crash) // 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 (BostonNYC in the available window)
// The critical assertion is that this code path does NOT crash (no force-unwrap trap).
switch result { switch result {
case .success(let options): case .success(let options):
#expect(!options.isEmpty, "If success, should have at least one option") #expect(!options.isEmpty, "If success, should have at least one option")
case .failure: case .failure(let failure):
break // Failure is acceptable the point is it didn't crash // 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)")
} }
} }
} }

View File

@@ -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")

View File

@@ -685,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 (NYCLA 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

View File

@@ -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")

View File

@@ -232,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)")
} }
} }
@@ -411,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

View File

@@ -533,7 +533,7 @@ struct TravelIntegrity_EdgeCaseTests {
let result = ItineraryBuilder.build(stops: stops, constraints: .default) let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result != nil, "Same-city stops should build") #expect(result != nil, "Same-city stops should build")
#expect(result!.travelSegments.count == 1, "Must still have segment") #expect(result!.travelSegments.count == 1, "Must still have segment")
// Distance should be very small (same coords) #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") @Test("Cross-country trip (NYC→LA) rejected with 1 driver, feasible with 2")

View File

@@ -32,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")
@@ -89,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")
@@ -102,6 +104,10 @@ 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 // MARK: - Travel Segment Validation
@@ -189,7 +195,7 @@ struct TripPlanningEngineTests {
} }
} }
@Test("planTrip: invalid options are filtered out") @Test("ItineraryOption.isValid: correctly validates segment count")
func planTrip_invalidOptions_areFilteredOut() { func planTrip_invalidOptions_areFilteredOut() {
// Create a valid ItineraryOption manually with wrong segment count // Create a valid ItineraryOption manually with wrong segment count
let stop1 = ItineraryStop( let stop1 = ItineraryStop(

View File

@@ -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")
} }
} }

View File

@@ -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)
} }
} }