- 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>
320 lines
8.7 KiB
Swift
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 }
|
|
}
|