From a4db9a92eb4bbfe2206b66812c0eebad099631f3 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 10 Jan 2026 11:42:31 -0600 Subject: [PATCH] test(08-01): GameDAGRouter edge case tests Add 10 TDD tests for GameDAGRouter covering: - Empty games array returns empty routes - Single game returns single-game route - Single game with non-matching anchor returns empty - Two chronological feasible games returns combined route - Two games too far apart same day returns separate routes - Two games reverse chronological returns separate routes - Three games with only feasible pairs returns valid combinations - Anchor filtering excludes routes missing anchors - Repeat cities OFF excludes same city twice - Repeat cities ON allows same city twice Co-Authored-By: Claude Opus 4.5 --- SportsTimeTests/GameDAGRouterTests.swift | 345 +++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 SportsTimeTests/GameDAGRouterTests.swift diff --git a/SportsTimeTests/GameDAGRouterTests.swift b/SportsTimeTests/GameDAGRouterTests.swift new file mode 100644 index 0000000..ee686de --- /dev/null +++ b/SportsTimeTests/GameDAGRouterTests.swift @@ -0,0 +1,345 @@ +// +// GameDAGRouterTests.swift +// SportsTimeTests +// +// TDD edge case tests for GameDAGRouter. +// Tests define correctness - code must match. +// + +import Testing +import Foundation +import CoreLocation +@testable import SportsTime + +@Suite("GameDAGRouter Edge Case Tests") +struct GameDAGRouterTests { + + // MARK: - Test Helpers + + private func makeStadium( + id: UUID = UUID(), + city: String, + lat: Double = 34.0, + lon: Double = -118.0 + ) -> Stadium { + Stadium( + id: id, + name: "\(city) Stadium", + city: city, + state: "XX", + latitude: lat, + longitude: lon, + capacity: 40000, + sport: .mlb + ) + } + + private func makeGame( + id: UUID = UUID(), + stadiumId: UUID, + startTime: Date + ) -> Game { + Game( + id: id, + homeTeamId: UUID(), + awayTeamId: UUID(), + stadiumId: stadiumId, + dateTime: startTime, + sport: .mlb, + season: "2026" + ) + } + + private func date(_ string: String) -> Date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.timeZone = TimeZone(identifier: "America/Los_Angeles") + return formatter.date(from: string)! + } + + // MARK: - Standard Test Stadiums (spread across US) + + private var losAngelesStadium: Stadium { + makeStadium(city: "Los Angeles", lat: 34.0739, lon: -118.2400) + } + + private var sanFranciscoStadium: Stadium { + makeStadium(city: "San Francisco", lat: 37.7786, lon: -122.3893) + } + + private var newYorkStadium: Stadium { + makeStadium(city: "New York", lat: 40.8296, lon: -73.9262) + } + + private var chicagoStadium: Stadium { + makeStadium(city: "Chicago", lat: 41.9484, lon: -87.6553) + } + + // MARK: - Test 1: Empty games array returns empty routes + + @Test("Empty games array returns empty routes") + func findRoutes_EmptyGames_ReturnsEmpty() { + let result = GameDAGRouter.findRoutes( + games: [], + stadiums: [:], + constraints: .default + ) + + #expect(result.isEmpty) + } + + // MARK: - Test 2: Single game returns that game + + @Test("Single game returns single-game route") + func findRoutes_SingleGame_ReturnsSingleRoute() { + let stadium = losAngelesStadium + let game = makeGame(stadiumId: stadium.id, startTime: date("2026-06-15 19:00")) + + let result = GameDAGRouter.findRoutes( + games: [game], + stadiums: [stadium.id: stadium], + constraints: .default + ) + + #expect(result.count == 1) + #expect(result.first?.count == 1) + #expect(result.first?.first?.id == game.id) + } + + // MARK: - Test 3: Single game with non-matching anchor returns empty + + @Test("Single game with non-matching anchor returns empty") + func findRoutes_SingleGame_NonMatchingAnchor_ReturnsEmpty() { + let stadium = losAngelesStadium + let game = makeGame(stadiumId: stadium.id, startTime: date("2026-06-15 19:00")) + let nonExistentAnchor = UUID() + + let result = GameDAGRouter.findRoutes( + games: [game], + stadiums: [stadium.id: stadium], + constraints: .default, + anchorGameIds: [nonExistentAnchor] + ) + + #expect(result.isEmpty) + } + + // MARK: - Test 4: Two games, chronological and feasible, returns route with both + + @Test("Two chronological feasible games returns combined route") + func findRoutes_TwoGames_Chronological_Feasible_ReturnsCombined() { + let la = losAngelesStadium + let sf = sanFranciscoStadium + + // LA to SF is ~380 miles, ~6 hours drive + // Games are 2 days apart - plenty of time + let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 19:00")) + let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-17 19:00")) + + let result = GameDAGRouter.findRoutes( + games: [game1, game2], + stadiums: [la.id: la, sf.id: sf], + constraints: .default + ) + + // Should have route containing both games + let routeWithBoth = result.first { route in + route.count == 2 && + route.contains { $0.id == game1.id } && + route.contains { $0.id == game2.id } + } + + #expect(routeWithBoth != nil, "Should have a route with both games") + } + + // MARK: - Test 5: Two games, chronological but infeasible (too far), returns separate routes + + @Test("Two games too far apart same day returns separate routes") + func findRoutes_TwoGames_TooFar_SameDay_ReturnsSeparate() { + let la = losAngelesStadium + let ny = newYorkStadium + + // LA to NY is ~2800 miles - impossible in same day + let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 14:00")) + let game2 = makeGame(stadiumId: ny.id, startTime: date("2026-06-15 20:00")) + + let result = GameDAGRouter.findRoutes( + games: [game1, game2], + stadiums: [la.id: la, ny.id: ny], + constraints: .default + ) + + // Should return two separate single-game routes + #expect(result.count == 2, "Should return 2 separate routes") + + let singleGameRoutes = result.filter { $0.count == 1 } + #expect(singleGameRoutes.count == 2, "Both routes should have single game") + } + + // MARK: - Test 6: Two games, reverse chronological, returns separate routes + + @Test("Two games reverse chronological returns separate routes") + func findRoutes_TwoGames_ReverseChronological_ReturnsSeparate() { + let la = losAngelesStadium + let sf = sanFranciscoStadium + + // game2 starts BEFORE game1 + let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-17 19:00")) + let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-15 19:00")) + + let result = GameDAGRouter.findRoutes( + games: [game1, game2], + stadiums: [la.id: la, sf.id: sf], + constraints: .default + ) + + // With no anchors, it should return the combined route (sorted chronologically) + // OR separate routes if that's what the algorithm does + // The key point is we get some result, not empty + #expect(!result.isEmpty, "Should return at least one route") + + // Check if combined route exists (sorted game2 -> game1) + let combined = result.first { route in + route.count == 2 && + route[0].id == game2.id && // SF game (earlier) + route[1].id == game1.id // LA game (later) + } + + if combined == nil { + // If no combined route, should have separate single-game routes + #expect(result.count >= 2, "Without combined route, should have separate routes") + } + } + + // MARK: - Test 7: Three games where only pairs are feasible + + @Test("Three games with only feasible pairs returns valid combinations") + func findRoutes_ThreeGames_OnlyPairsFeasible_ReturnsValidCombinations() { + let la = losAngelesStadium + let sf = sanFranciscoStadium + let ny = newYorkStadium + + // Day 1: LA game + // Day 2: SF game (feasible from LA) + // Day 2: NY game (NOT feasible from LA same day) + let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 14:00")) + let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-16 19:00")) + let game3 = makeGame(stadiumId: ny.id, startTime: date("2026-06-16 20:00")) + + let result = GameDAGRouter.findRoutes( + games: [game1, game2, game3], + stadiums: [la.id: la, sf.id: sf, ny.id: ny], + constraints: .default + ) + + // Should return routes: + // - LA -> SF (feasible pair) + // - LA alone + // - SF alone + // - NY alone + // No LA -> NY same day (infeasible) + #expect(!result.isEmpty, "Should return at least one route") + + // Verify no route has LA then NY on same day + for route in result { + let hasLA = route.contains { $0.stadiumId == la.id } + let hasNY = route.contains { $0.stadiumId == ny.id } + + if hasLA && hasNY { + // If both, they shouldn't be consecutive on same day + let laIndex = route.firstIndex { $0.stadiumId == la.id }! + let nyIndex = route.firstIndex { $0.stadiumId == ny.id }! + + if nyIndex == laIndex + 1 { + let laGame = route[laIndex] + let nyGame = route[nyIndex] + let calendar = Calendar.current + let sameDay = calendar.isDate(laGame.startTime, inSameDayAs: nyGame.startTime) + #expect(!sameDay, "LA -> NY same day should not be feasible") + } + } + } + } + + // MARK: - Test 8: Anchor game filtering - routes missing anchors excluded + + @Test("Routes missing anchor games are excluded") + func findRoutes_AnchorFiltering_ExcludesRoutesMissingAnchors() { + let la = losAngelesStadium + let sf = sanFranciscoStadium + + let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 19:00")) + let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-17 19:00")) + + // Anchor on game2 - all routes must include it + let result = GameDAGRouter.findRoutes( + games: [game1, game2], + stadiums: [la.id: la, sf.id: sf], + constraints: .default, + anchorGameIds: [game2.id] + ) + + // Every returned route must contain game2 + for route in result { + let containsAnchor = route.contains { $0.id == game2.id } + #expect(containsAnchor, "Every route must contain anchor game") + } + } + + // MARK: - Test 9: Repeat cities OFF - routes with same city twice excluded + + @Test("Repeat cities OFF excludes routes visiting same city twice") + func findRoutes_RepeatCitiesOff_ExcludesSameCityTwice() { + let la1 = makeStadium(id: UUID(), city: "Los Angeles", lat: 34.0739, lon: -118.2400) + let la2 = makeStadium(id: UUID(), city: "Los Angeles", lat: 34.0140, lon: -118.2879) + let sf = sanFranciscoStadium + + // Two stadiums in LA (different venues), one in SF + let game1 = makeGame(stadiumId: la1.id, startTime: date("2026-06-15 19:00")) + let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-16 19:00")) + let game3 = makeGame(stadiumId: la2.id, startTime: date("2026-06-17 19:00")) + + let result = GameDAGRouter.findRoutes( + games: [game1, game2, game3], + stadiums: [la1.id: la1, la2.id: la2, sf.id: sf], + constraints: .default, + allowRepeatCities: false + ) + + // No route should have both LA stadiums (same city) + for route in result { + let laCities = route.filter { game in + [la1.id, la2.id].contains(game.stadiumId) + }.count + #expect(laCities <= 1, "With repeat cities OFF, can't visit LA twice") + } + } + + // MARK: - Test 10: Repeat cities ON - routes with same city twice included + + @Test("Repeat cities ON allows routes visiting same city twice") + func findRoutes_RepeatCitiesOn_AllowsSameCityTwice() { + let la1 = makeStadium(id: UUID(), city: "Los Angeles", lat: 34.0739, lon: -118.2400) + let la2 = makeStadium(id: UUID(), city: "Los Angeles", lat: 34.0140, lon: -118.2879) + + // Two games at different LA stadiums + let game1 = makeGame(stadiumId: la1.id, startTime: date("2026-06-15 19:00")) + let game2 = makeGame(stadiumId: la2.id, startTime: date("2026-06-17 19:00")) + + let result = GameDAGRouter.findRoutes( + games: [game1, game2], + stadiums: [la1.id: la1, la2.id: la2], + constraints: .default, + allowRepeatCities: true + ) + + // Should have a route with both games (both in LA) + let routeWithBoth = result.first { route in + route.count == 2 && + route.contains { $0.id == game1.id } && + route.contains { $0.id == game2.id } + } + + #expect(routeWithBoth != nil, "With repeat cities ON, should allow both LA games") + } +}