Files
Sportstime/SportsTimeTests/Mocks/MockAppDataProvider.swift
Trey t f180e5bfed feat(sync): add CloudKit sync for dynamic sports
- Add CKSport model to parse CloudKit Sport records
- Add fetchSportsForSync() to CloudKitService for delta fetching
- Add syncSports() and mergeSport() to CanonicalSyncService
- Update DataProvider with dynamicSports support and allSports computed property
- Update MockAppDataProvider with matching dynamic sports support
- Add comprehensive documentation for adding new sports

The app can now sync sport definitions from CloudKit, enabling new sports
to be added without app updates. Sports are fetched, merged into SwiftData,
and exposed via AppDataProvider.allSports alongside built-in Sport enum cases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:27:56 -06:00

320 lines
8.7 KiB
Swift

//
// MockAppDataProvider.swift
// SportsTimeTests
//
// Mock implementation of AppDataProvider for testing without SwiftData dependencies.
//
import Foundation
import Combine
@testable import SportsTime
// MARK: - Mock App Data Provider
@MainActor
final class MockAppDataProvider: ObservableObject {
// MARK: - Published State
@Published private(set) var teams: [Team] = []
@Published private(set) var stadiums: [Stadium] = []
@Published private(set) var dynamicSports: [DynamicSport] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
@Published private(set) var errorMessage: String?
// MARK: - Internal Storage
private var teamsById: [String: Team] = [:]
private var stadiumsById: [String: Stadium] = [:]
private var dynamicSportsById: [String: DynamicSport] = [:]
private var games: [Game] = []
private var gamesById: [String: Game] = [:]
// MARK: - Configuration
struct Configuration {
var simulatedLatency: TimeInterval = 0
var shouldFailOnLoad: Bool = false
var shouldFailOnFetch: Bool = false
var isEmpty: Bool = false
static var `default`: Configuration { Configuration() }
static var empty: Configuration { Configuration(isEmpty: true) }
static var failing: Configuration { Configuration(shouldFailOnLoad: true) }
static var slow: Configuration { Configuration(simulatedLatency: 1.0) }
}
private var config: Configuration
// MARK: - Call Tracking
private(set) var loadInitialDataCallCount = 0
private(set) var filterGamesCallCount = 0
private(set) var filterRichGamesCallCount = 0
private(set) var allGamesCallCount = 0
private(set) var allRichGamesCallCount = 0
// MARK: - Initialization
init(config: Configuration = .default) {
self.config = config
}
// MARK: - Configuration Methods
func configure(_ newConfig: Configuration) {
self.config = newConfig
}
func setTeams(_ newTeams: [Team]) {
self.teams = newTeams
self.teamsById = Dictionary(uniqueKeysWithValues: newTeams.map { ($0.id, $0) })
}
func setStadiums(_ newStadiums: [Stadium]) {
self.stadiums = newStadiums
self.stadiumsById = Dictionary(uniqueKeysWithValues: newStadiums.map { ($0.id, $0) })
}
func setGames(_ newGames: [Game]) {
self.games = newGames
self.gamesById = Dictionary(uniqueKeysWithValues: newGames.map { ($0.id, $0) })
}
func setDynamicSports(_ newSports: [DynamicSport]) {
self.dynamicSports = newSports
self.dynamicSportsById = Dictionary(uniqueKeysWithValues: newSports.map { ($0.id, $0) })
}
func reset() {
teams = []
stadiums = []
dynamicSports = []
games = []
teamsById = [:]
stadiumsById = [:]
dynamicSportsById = [:]
gamesById = [:]
isLoading = false
error = nil
errorMessage = nil
loadInitialDataCallCount = 0
filterGamesCallCount = 0
filterRichGamesCallCount = 0
allGamesCallCount = 0
allRichGamesCallCount = 0
config = .default
}
// MARK: - Simulated Network
private func simulateLatency() async {
if config.simulatedLatency > 0 {
try? await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000))
}
}
// MARK: - Data Loading
func loadInitialData() async {
loadInitialDataCallCount += 1
if config.isEmpty {
teams = []
stadiums = []
return
}
isLoading = true
error = nil
errorMessage = nil
await simulateLatency()
if config.shouldFailOnLoad {
error = DataProviderError.contextNotConfigured
errorMessage = "Mock load failure"
isLoading = false
return
}
isLoading = false
}
func clearError() {
error = nil
errorMessage = nil
}
func retry() async {
await loadInitialData()
}
// MARK: - Data Access
func team(for id: String) -> Team? {
teamsById[id]
}
func stadium(for id: String) -> Stadium? {
stadiumsById[id]
}
func teams(for sport: Sport) -> [Team] {
teams.filter { $0.sport == sport }
}
func dynamicSport(for id: String) -> DynamicSport? {
dynamicSportsById[id]
}
/// All sports: built-in Sport enum cases + CloudKit-defined DynamicSports
var allSports: [any AnySport] {
let builtIn: [any AnySport] = Sport.allCases
let dynamic: [any AnySport] = dynamicSports
return builtIn + dynamic
}
// MARK: - Game Filtering (Local Queries)
func filterGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
filterGamesCallCount += 1
await simulateLatency()
if config.shouldFailOnFetch {
throw DataProviderError.contextNotConfigured
}
return games.filter { game in
sports.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}.sorted { $0.dateTime < $1.dateTime }
}
func allGames(for sports: Set<Sport>) async throws -> [Game] {
allGamesCallCount += 1
await simulateLatency()
if config.shouldFailOnFetch {
throw DataProviderError.contextNotConfigured
}
return games.filter { game in
sports.contains(game.sport)
}.sorted { $0.dateTime < $1.dateTime }
}
func fetchGame(by id: String) async throws -> Game? {
await simulateLatency()
if config.shouldFailOnFetch {
throw DataProviderError.contextNotConfigured
}
return gamesById[id]
}
func filterRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
filterRichGamesCallCount += 1
let filteredGames = try await filterGames(sports: sports, startDate: startDate, endDate: endDate)
return filteredGames.compactMap { game in
richGame(from: game)
}
}
func allRichGames(for sports: Set<Sport>) async throws -> [RichGame] {
allRichGamesCallCount += 1
let allFilteredGames = try await allGames(for: sports)
return allFilteredGames.compactMap { game in
richGame(from: game)
}
}
func richGame(from game: Game) -> RichGame? {
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
// MARK: - Convenience Extensions
extension MockAppDataProvider {
/// Load fixture data from FixtureGenerator
func loadFixtures(_ data: FixtureGenerator.GeneratedData) {
setTeams(data.teams)
setStadiums(data.stadiums)
setGames(data.games)
}
/// Create a mock provider with fixture data pre-loaded
static func withFixtures(_ config: FixtureGenerator.Configuration = .default) -> MockAppDataProvider {
let mock = MockAppDataProvider()
let data = FixtureGenerator.generate(with: config)
mock.loadFixtures(data)
return mock
}
/// Create a mock provider configured as empty
static var empty: MockAppDataProvider {
MockAppDataProvider(config: .empty)
}
/// Create a mock provider configured to fail
static var failing: MockAppDataProvider {
MockAppDataProvider(config: .failing)
}
}
// MARK: - Test Helpers
extension MockAppDataProvider {
/// Add a single game
func addGame(_ game: Game) {
games.append(game)
gamesById[game.id] = game
}
/// Add a single team
func addTeam(_ team: Team) {
teams.append(team)
teamsById[team.id] = team
}
/// Add a single stadium
func addStadium(_ stadium: Stadium) {
stadiums.append(stadium)
stadiumsById[stadium.id] = stadium
}
/// Get all stored games (for test verification)
func getAllStoredGames() -> [Game] {
games
}
/// Get games count
var gamesCount: Int { games.count }
/// Get teams count
var teamsCount: Int { teams.count }
/// Get stadiums count
var stadiumsCount: Int { stadiums.count }
/// Add a single dynamic sport
func addDynamicSport(_ sport: DynamicSport) {
dynamicSports.append(sport)
dynamicSportsById[sport.id] = sport
}
/// Get dynamic sports count
var dynamicSportsCount: Int { dynamicSports.count }
}