Files
Sportstime/SportsTimeTests/Planning/GameDAGRouterScaleTests.swift
Trey t 1bd248c255 test(planning): complete test suite with Phase 11 edge cases
Implement comprehensive test infrastructure and all 124 tests across 11 phases:

- Phase 0: Test infrastructure (fixtures, mocks, helpers)
- Phases 1-10: Core planning engine tests (previously implemented)
- Phase 11: Edge case omnibus (11 new tests)
  - Data edge cases: nil stadiums, malformed dates, invalid coordinates
  - Boundary conditions: driving limits, radius boundaries
  - Time zone cases: cross-timezone games, DST transitions

Reorganize test structure under Planning/ directory with proper organization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:14:40 -06:00

395 lines
15 KiB
Swift

//
// GameDAGRouterScaleTests.swift
// SportsTimeTests
//
// Phase 3: GameDAGRouter Scale & Performance Tests
// Stress tests for large datasets. May run for extended periods.
//
import Testing
import Foundation
@testable import SportsTime
@Suite("GameDAGRouter Scale & Performance Tests")
struct GameDAGRouterScaleTests {
// MARK: - 3A: Scale Tests
@Test("3.1 - 5 games completes within 5 minutes")
func test_findRoutes_5Games_CompletesWithin5Minutes() async throws {
let config = FixtureGenerator.Configuration(
seed: 31,
gameCount: 5,
stadiumCount: 5,
teamCount: 5,
geographicSpread: .regional
)
let data = FixtureGenerator.generate(with: config)
let startTime = Date()
let routes = GameDAGRouter.findRoutes(
games: data.games,
stadiums: data.stadiumsById,
constraints: .default
)
let elapsed = Date().timeIntervalSince(startTime)
#expect(elapsed < TestConstants.performanceTimeout, "Should complete within 5 minutes")
#expect(!routes.isEmpty, "Should produce at least one route")
// Verify route validity
for route in routes {
#expect(!route.isEmpty, "Routes should not be empty")
for i in 0..<(route.count - 1) {
#expect(route[i].startTime <= route[i + 1].startTime, "Games should be chronologically ordered")
}
}
print("3.1 - 5 games: \(routes.count) routes in \(String(format: "%.2f", elapsed))s")
}
@Test("3.2 - 50 games completes within 5 minutes")
func test_findRoutes_50Games_CompletesWithin5Minutes() async throws {
let config = FixtureGenerator.Configuration(
seed: 32,
gameCount: 50,
stadiumCount: 15,
teamCount: 15,
geographicSpread: .regional
)
let data = FixtureGenerator.generate(with: config)
let startTime = Date()
let routes = GameDAGRouter.findRoutes(
games: data.games,
stadiums: data.stadiumsById,
constraints: .default
)
let elapsed = Date().timeIntervalSince(startTime)
#expect(elapsed < TestConstants.performanceTimeout, "Should complete within 5 minutes")
#expect(!routes.isEmpty, "Should produce routes")
// Verify route validity
for route in routes {
for i in 0..<(route.count - 1) {
#expect(route[i].startTime <= route[i + 1].startTime, "Games should be chronologically ordered")
}
}
print("3.2 - 50 games: \(routes.count) routes in \(String(format: "%.2f", elapsed))s")
}
@Test("3.3 - 500 games completes within 5 minutes")
func test_findRoutes_500Games_CompletesWithin5Minutes() async throws {
let config = FixtureGenerator.Configuration.medium // 500 games
let data = FixtureGenerator.generate(with: config)
let startTime = Date()
let routes = GameDAGRouter.findRoutes(
games: data.games,
stadiums: data.stadiumsById,
constraints: .default
)
let elapsed = Date().timeIntervalSince(startTime)
#expect(elapsed < TestConstants.performanceTimeout, "Should complete within 5 minutes")
#expect(!routes.isEmpty, "Should produce routes")
// Verify route validity
for route in routes {
for i in 0..<(route.count - 1) {
#expect(route[i].startTime <= route[i + 1].startTime, "Games should be chronologically ordered")
}
}
print("3.3 - 500 games: \(routes.count) routes in \(String(format: "%.2f", elapsed))s")
// Record baseline if not set
if TestConstants.baseline500Games == 0 {
print("BASELINE 500 games: \(elapsed)s (record this in TestConstants)")
}
}
@Test("3.4 - 2000 games completes within 5 minutes")
func test_findRoutes_2000Games_CompletesWithin5Minutes() async throws {
let config = FixtureGenerator.Configuration.large // 2000 games
let data = FixtureGenerator.generate(with: config)
let startTime = Date()
let routes = GameDAGRouter.findRoutes(
games: data.games,
stadiums: data.stadiumsById,
constraints: .default
)
let elapsed = Date().timeIntervalSince(startTime)
#expect(elapsed < TestConstants.performanceTimeout, "Should complete within 5 minutes")
#expect(!routes.isEmpty, "Should produce routes")
// Verify route validity
for route in routes {
for i in 0..<(route.count - 1) {
#expect(route[i].startTime <= route[i + 1].startTime, "Games should be chronologically ordered")
}
}
print("3.4 - 2000 games: \(routes.count) routes in \(String(format: "%.2f", elapsed))s")
// Record baseline if not set
if TestConstants.baseline2000Games == 0 {
print("BASELINE 2000 games: \(elapsed)s (record this in TestConstants)")
}
}
@Test("3.5 - 10000 games completes within 5 minutes")
func test_findRoutes_10000Games_CompletesWithin5Minutes() async throws {
let config = FixtureGenerator.Configuration.stress // 10000 games
let data = FixtureGenerator.generate(with: config)
let startTime = Date()
let routes = GameDAGRouter.findRoutes(
games: data.games,
stadiums: data.stadiumsById,
constraints: .default
)
let elapsed = Date().timeIntervalSince(startTime)
#expect(elapsed < TestConstants.performanceTimeout, "Should complete within 5 minutes")
#expect(!routes.isEmpty, "Should produce routes")
// Verify route validity
for route in routes {
for i in 0..<(route.count - 1) {
#expect(route[i].startTime <= route[i + 1].startTime, "Games should be chronologically ordered")
}
}
print("3.5 - 10000 games: \(routes.count) routes in \(String(format: "%.2f", elapsed))s")
// Record baseline if not set
if TestConstants.baseline10000Games == 0 {
print("BASELINE 10000 games: \(elapsed)s (record this in TestConstants)")
}
}
@Test("3.6 - 50000 nodes completes within 5 minutes")
func test_findRoutes_50000Nodes_CompletesWithin5Minutes() async throws {
// Extreme stress test - 50000 games
let config = FixtureGenerator.Configuration(
seed: 36,
gameCount: 50000,
stadiumCount: 30,
teamCount: 60,
geographicSpread: .nationwide
)
let data = FixtureGenerator.generate(with: config)
let startTime = Date()
let routes = GameDAGRouter.findRoutes(
games: data.games,
stadiums: data.stadiumsById,
constraints: .default,
beamWidth: 25 // Reduced beam width for extreme scale
)
let elapsed = Date().timeIntervalSince(startTime)
#expect(elapsed < TestConstants.performanceTimeout, "Should complete within 5 minutes (may need timeout adjustment)")
// Routes may be empty for extreme stress test - that's acceptable if it completes
print("3.6 - 50000 games: \(routes.count) routes in \(String(format: "%.2f", elapsed))s")
}
// MARK: - 3B: Performance Baselines
@Test("3.7 - Record baseline times for 500/2000/10000 games")
func test_recordBaselineTimes() async throws {
// Run each size and record times for baseline establishment
var baselines: [(size: Int, time: TimeInterval)] = []
// 500 games
let config500 = FixtureGenerator.Configuration.medium
let data500 = FixtureGenerator.generate(with: config500)
let start500 = Date()
_ = GameDAGRouter.findRoutes(
games: data500.games,
stadiums: data500.stadiumsById,
constraints: .default
)
let elapsed500 = Date().timeIntervalSince(start500)
baselines.append((500, elapsed500))
// 2000 games
let config2000 = FixtureGenerator.Configuration.large
let data2000 = FixtureGenerator.generate(with: config2000)
let start2000 = Date()
_ = GameDAGRouter.findRoutes(
games: data2000.games,
stadiums: data2000.stadiumsById,
constraints: .default
)
let elapsed2000 = Date().timeIntervalSince(start2000)
baselines.append((2000, elapsed2000))
// 10000 games
let config10000 = FixtureGenerator.Configuration.stress
let data10000 = FixtureGenerator.generate(with: config10000)
let start10000 = Date()
_ = GameDAGRouter.findRoutes(
games: data10000.games,
stadiums: data10000.stadiumsById,
constraints: .default
)
let elapsed10000 = Date().timeIntervalSince(start10000)
baselines.append((10000, elapsed10000))
// Print baselines for recording
print("\n=== PERFORMANCE BASELINES ===")
for baseline in baselines {
print("\(baseline.size) games: \(String(format: "%.3f", baseline.time))s")
}
print("==============================\n")
// All should complete within timeout
for baseline in baselines {
#expect(baseline.time < TestConstants.performanceTimeout, "\(baseline.size) games should complete within timeout")
}
}
@Test("3.8 - Performance regression assertions")
func test_performanceRegressionAssertions() async throws {
// Skip if baselines not yet established
guard TestConstants.baseline500Games > 0 else {
print("Skipping regression test - baselines not yet recorded")
return
}
// 500 games - compare to baseline with 50% tolerance
let config500 = FixtureGenerator.Configuration.medium
let data500 = FixtureGenerator.generate(with: config500)
let start500 = Date()
_ = GameDAGRouter.findRoutes(
games: data500.games,
stadiums: data500.stadiumsById,
constraints: .default
)
let elapsed500 = Date().timeIntervalSince(start500)
let tolerance500 = TestConstants.baseline500Games * 1.5
#expect(elapsed500 <= tolerance500, "500 games should not regress more than 50% from baseline (\(TestConstants.baseline500Games)s)")
// 2000 games
if TestConstants.baseline2000Games > 0 {
let config2000 = FixtureGenerator.Configuration.large
let data2000 = FixtureGenerator.generate(with: config2000)
let start2000 = Date()
_ = GameDAGRouter.findRoutes(
games: data2000.games,
stadiums: data2000.stadiumsById,
constraints: .default
)
let elapsed2000 = Date().timeIntervalSince(start2000)
let tolerance2000 = TestConstants.baseline2000Games * 1.5
#expect(elapsed2000 <= tolerance2000, "2000 games should not regress more than 50% from baseline")
}
// 10000 games
if TestConstants.baseline10000Games > 0 {
let config10000 = FixtureGenerator.Configuration.stress
let data10000 = FixtureGenerator.generate(with: config10000)
let start10000 = Date()
_ = GameDAGRouter.findRoutes(
games: data10000.games,
stadiums: data10000.stadiumsById,
constraints: .default
)
let elapsed10000 = Date().timeIntervalSince(start10000)
let tolerance10000 = TestConstants.baseline10000Games * 1.5
#expect(elapsed10000 <= tolerance10000, "10000 games should not regress more than 50% from baseline")
}
}
// MARK: - 3C: Memory Tests
@Test("3.9 - Repeated calls show no memory leak")
func test_findRoutes_RepeatedCalls_NoMemoryLeak() async throws {
// Run 100 iterations with medium dataset and verify no memory growth
let config = FixtureGenerator.Configuration(
seed: 39,
gameCount: 100,
stadiumCount: 15,
teamCount: 15,
geographicSpread: .regional
)
let data = FixtureGenerator.generate(with: config)
// Get initial memory footprint (rough approximation)
let initialMemory = getMemoryUsageMB()
// Run 100 iterations
for iteration in 0..<100 {
autoreleasepool {
_ = GameDAGRouter.findRoutes(
games: data.games,
stadiums: data.stadiumsById,
constraints: .default
)
}
// Check memory every 20 iterations
if iteration > 0 && iteration % 20 == 0 {
let currentMemory = getMemoryUsageMB()
print("Iteration \(iteration): Memory usage \(String(format: "%.1f", currentMemory)) MB")
}
}
let finalMemory = getMemoryUsageMB()
let memoryGrowth = finalMemory - initialMemory
print("Memory test: Initial=\(String(format: "%.1f", initialMemory))MB, Final=\(String(format: "%.1f", finalMemory))MB, Growth=\(String(format: "%.1f", memoryGrowth))MB")
// Allow up to 50MB growth (reasonable for 100 iterations with route caching)
#expect(memoryGrowth < 50.0, "Memory should not grow excessively over 100 iterations (grew \(memoryGrowth)MB)")
}
@Test("3.10 - Large dataset memory bounded")
func test_findRoutes_LargeDataset_MemoryBounded() async throws {
// 10K games should not exceed reasonable memory
let config = FixtureGenerator.Configuration.stress // 10000 games
let data = FixtureGenerator.generate(with: config)
let beforeMemory = getMemoryUsageMB()
_ = GameDAGRouter.findRoutes(
games: data.games,
stadiums: data.stadiumsById,
constraints: .default
)
let afterMemory = getMemoryUsageMB()
let memoryUsed = afterMemory - beforeMemory
print("10K games memory: Before=\(String(format: "%.1f", beforeMemory))MB, After=\(String(format: "%.1f", afterMemory))MB, Used=\(String(format: "%.1f", memoryUsed))MB")
// 10K games with 30 stadiums should not use more than 500MB
#expect(memoryUsed < 500.0, "10K games should not use more than 500MB (used \(memoryUsed)MB)")
}
// MARK: - Helper Functions
/// Returns current memory usage in MB (approximate)
private func getMemoryUsageMB() -> Double {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
let result = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
guard result == KERN_SUCCESS else { return 0 }
return Double(info.resident_size) / 1024.0 / 1024.0
}
}