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>
395 lines
15 KiB
Swift
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
|
|
}
|
|
}
|