From 94f85f8ab845a6686180af01387337bfd92c93b4 Mon Sep 17 00:00:00 2001 From: Michael Virgo Date: Tue, 21 May 2024 00:07:38 -0500 Subject: [PATCH] test: Bracket creation and simulation tests (#8) * feat: Color styling for pending bracket view games * test: Add bracket simulation tests * feat: Filter by league for live game scores * chore: Asset symbol extension setting * feat: Add refresh button for live games --- foam-madness-tests/CoreDataTestStack.swift | 30 +++++++ ...foam_madness_sim_custom_bracket_test.swift | 82 +++++++++++++++++++ ...am_madness_sim_existing_bracket_test.swift | 42 ++++++++++ foam-madness.xcodeproj/project.pbxproj | 20 ++++- foam-madness/Controller/AppConstants.swift | 1 + .../Model/Live Games API/LiveGamesModel.swift | 53 ++++++++++++ foam-madness/View/Live/LiveGamesView.swift | 81 +++++++++--------- .../BracketView/BracketGameCell.swift | 15 ++-- .../BracketView/BracketWinnerLine.swift | 5 +- 9 files changed, 278 insertions(+), 51 deletions(-) create mode 100644 foam-madness-tests/CoreDataTestStack.swift create mode 100644 foam-madness-tests/foam_madness_sim_custom_bracket_test.swift create mode 100644 foam-madness-tests/foam_madness_sim_existing_bracket_test.swift create mode 100644 foam-madness/Model/Live Games API/LiveGamesModel.swift diff --git a/foam-madness-tests/CoreDataTestStack.swift b/foam-madness-tests/CoreDataTestStack.swift new file mode 100644 index 0000000..5c6ea4c --- /dev/null +++ b/foam-madness-tests/CoreDataTestStack.swift @@ -0,0 +1,30 @@ +// +// CoreDataTestStack.swift +// foam-madness-tests +// +// Created by Michael Virgo on 5/20/24. +// Copyright © 2024 mvirgo. All rights reserved. +// + +import CoreData + +class CoreDataTestStack { + static let shared = CoreDataTestStack() + + private static var persistentContainer: NSPersistentContainer = { + let container = NSPersistentContainer(name: "foam-madness") + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + container.loadPersistentStores { (storeDescription, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + return container + }() + + var context: NSManagedObjectContext { + return Self.persistentContainer.viewContext + } +} diff --git a/foam-madness-tests/foam_madness_sim_custom_bracket_test.swift b/foam-madness-tests/foam_madness_sim_custom_bracket_test.swift new file mode 100644 index 0000000..f24079f --- /dev/null +++ b/foam-madness-tests/foam_madness_sim_custom_bracket_test.swift @@ -0,0 +1,82 @@ +// +// foam_madness_sim_custom_bracket_test.swift +// foam-madness-tests +// +// Created by Michael Virgo on 5/20/24. +// Copyright © 2024 mvirgo. All rights reserved. +// + +import CoreData +import XCTest +@testable import foam_madness + +final class foam_madness_sim_custom_bracket_test: XCTestCase { + var bracketCreationController: BracketCreationController! + var viewContext: NSManagedObjectContext! + + override func setUpWithError() throws { + viewContext = CoreDataTestStack.shared.context + bracketCreationController = BracketCreationController(context: viewContext) + } + + override func tearDownWithError() throws { + viewContext = nil + } + + func createCustomTournament(numTeams: Int, fillType: CustomType) -> Tournament { + return bracketCreationController.createCustomBracket( + numTeams: numTeams, + isWomens: false, + tournamentName: "\(fillType)\(numTeams)", + isSimulated: true, + useLeft: false, + shotsPerRound: AppConstants.defaultShotsPerRound) + } + + // Check all allowed custom bracket sizes with random fills + func testCreateAndSimRandomFillCustomBracket() throws { + for numTeams in numTeamsArray { + let tournament = createCustomTournament(numTeams: numTeams, fillType: CustomType.random) + bracketCreationController.fillTournamentWithRandomTeams(tournament) + let winner = bracketCreationController.simulateTournament(tournament: tournament) + + XCTAssert(winner != "") + } + } + + // Check all allowed custom bracket sizes with an existing bracket fill + func testCreateAndSimExistingFillCustomBracket() throws { + let brackets = BracketHelper.loadBrackets() + let fillBracket = brackets.randomElement()! + + for numTeams in numTeamsArray { + if (numTeams == 4) { + // Skip existing for 4 team bracket - not allowed + continue + } + let tournament = createCustomTournament(numTeams: numTeams, fillType: CustomType.existing) + bracketCreationController.fillTournamentFromExistingCustom(tournament, fillBracket.file) + let winner = bracketCreationController.simulateTournament(tournament: tournament) + + XCTAssert(winner != "") + } + } + + // Check creation of custom brackets has no teams initially filled + func testCreateCustomBracketHasNoTeams() throws { + for numTeams in numTeamsArray { + let tournament = createCustomTournament(numTeams: numTeams, fillType: CustomType.selectAll) + let games = Array(tournament.games!) as! [Game] + let minRound = games.min(by: { $0.round < $1.round })?.round ?? 0 + let gamesInMinRound = games.filter({ $0.round == minRound }).count + + XCTAssert(gamesInMinRound == numTeams / 2) + + for game in games { + if game.teams?.count ?? 0 > 0 { + XCTFail("Improperly found set teams in selectAll custom tournament of size \(numTeams)") + } + } + } + } +} diff --git a/foam-madness-tests/foam_madness_sim_existing_bracket_test.swift b/foam-madness-tests/foam_madness_sim_existing_bracket_test.swift new file mode 100644 index 0000000..d6f53ce --- /dev/null +++ b/foam-madness-tests/foam_madness_sim_existing_bracket_test.swift @@ -0,0 +1,42 @@ +// +// foam_madness_sim_existing_bracket_test.swift +// foam-madness-tests +// +// Created by Michael Virgo on 5/20/24. +// Copyright © 2024 mvirgo. All rights reserved. +// + +import CoreData +import XCTest +@testable import foam_madness + +final class foam_madness_sim_existing_bracket_test: XCTestCase { + var bracketCreationController: BracketCreationController! + var viewContext: NSManagedObjectContext! + + override func setUpWithError() throws { + viewContext = CoreDataTestStack.shared.context + bracketCreationController = BracketCreationController(context: viewContext) + } + + override func tearDownWithError() throws { + viewContext = nil + } + + func testCreateAndSimExistingBrackets() throws { + let brackets = BracketHelper.loadBrackets() + + for bracket in brackets { + let tournament = bracketCreationController.createBracketFromFile( + bracketLocation: bracket.file, + tournamentName: bracket.name, + isSimulated: true, + useLeft: false, + shotsPerRound: AppConstants.defaultShotsPerRound + ) + let winner = bracketCreationController.simulateTournament(tournament: tournament) + + XCTAssert(winner != "") + } + } +} diff --git a/foam-madness.xcodeproj/project.pbxproj b/foam-madness.xcodeproj/project.pbxproj index 08a4eef..acd99e0 100644 --- a/foam-madness.xcodeproj/project.pbxproj +++ b/foam-madness.xcodeproj/project.pbxproj @@ -60,6 +60,10 @@ D15865952B575ECE009E486A /* PrimaryButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D15865942B575ECE009E486A /* PrimaryButtonStyle.swift */; }; D158B32D2B9A96720002D2F6 /* GameStatsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D158B32C2B9A96720002D2F6 /* GameStatsController.swift */; }; D16485722BE9D7AC006BB362 /* BracketWinnerLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16485712BE9D7AB006BB362 /* BracketWinnerLine.swift */; }; + D168622F2BFC40F700649094 /* foam_madness_sim_existing_bracket_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = D168622E2BFC40F700649094 /* foam_madness_sim_existing_bracket_test.swift */; }; + D16862312BFC41F000649094 /* CoreDataTestStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16862302BFC41F000649094 /* CoreDataTestStack.swift */; }; + D16862332BFC450B00649094 /* foam_madness_sim_custom_bracket_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16862322BFC450B00649094 /* foam_madness_sim_custom_bracket_test.swift */; }; + D16862352BFC560000649094 /* LiveGamesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16862342BFC560000649094 /* LiveGamesModel.swift */; }; D16BEEB42BF7E605008D06B0 /* SelectInitialBracketShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16BEEB32BF7E605008D06B0 /* SelectInitialBracketShellView.swift */; }; D17FF6012BA140DA00149D63 /* SearchTeamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17FF6002BA140DA00149D63 /* SearchTeamView.swift */; }; D17FF6052BA1454900149D63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17FF6042BA1454900149D63 /* SearchBar.swift */; }; @@ -162,6 +166,10 @@ D15865942B575ECE009E486A /* PrimaryButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButtonStyle.swift; sourceTree = ""; }; D158B32C2B9A96720002D2F6 /* GameStatsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStatsController.swift; sourceTree = ""; }; D16485712BE9D7AB006BB362 /* BracketWinnerLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BracketWinnerLine.swift; sourceTree = ""; }; + D168622E2BFC40F700649094 /* foam_madness_sim_existing_bracket_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = foam_madness_sim_existing_bracket_test.swift; sourceTree = ""; }; + D16862302BFC41F000649094 /* CoreDataTestStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestStack.swift; sourceTree = ""; }; + D16862322BFC450B00649094 /* foam_madness_sim_custom_bracket_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = foam_madness_sim_custom_bracket_test.swift; sourceTree = ""; }; + D16862342BFC560000649094 /* LiveGamesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveGamesModel.swift; sourceTree = ""; }; D16BEEB32BF7E605008D06B0 /* SelectInitialBracketShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectInitialBracketShellView.swift; sourceTree = ""; }; D17FF6002BA140DA00149D63 /* SearchTeamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTeamView.swift; sourceTree = ""; }; D17FF6042BA1454900149D63 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; @@ -308,6 +316,7 @@ 7ACBCB35250EC71400EBA5B1 /* Live Games API */ = { isa = PBXGroup; children = ( + D16862342BFC560000649094 /* LiveGamesModel.swift */, 7ACBCB36250EC73900EBA5B1 /* APIClient.swift */, 7ACBCB38250EC7DA00EBA5B1 /* LiveGamesResponse.swift */, 7ACBCB3A250EC7FC00EBA5B1 /* ErrorResponse.swift */, @@ -490,6 +499,9 @@ D1A67BEE29BD6E4D00E1D55B /* foam_madness_teams_file_test.swift */, D1A67BEC29BD631D00E1D55B /* foam_madness_probability_file_test.swift */, D1A67BE529BD48E200E1D55B /* foam_madness_bracket_file_tests.swift */, + D168622E2BFC40F700649094 /* foam_madness_sim_existing_bracket_test.swift */, + D16862322BFC450B00649094 /* foam_madness_sim_custom_bracket_test.swift */, + D16862302BFC41F000649094 /* CoreDataTestStack.swift */, ); path = "foam-madness-tests"; sourceTree = ""; @@ -548,7 +560,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1420; - LastUpgradeCheck = 1520; + LastUpgradeCheck = 1530; ORGANIZATIONNAME = mvirgo; TargetAttributes = { 7A2ECEE4242EDE920065E369 = { @@ -641,6 +653,7 @@ D12027022BF7A00B00A10EA7 /* SelectYearView.swift in Sources */, D1C757A12B9A62C500817E1A /* TournamentStatsController.swift in Sources */, D15865802B5750F9009E486A /* ShootModeView.swift in Sources */, + D16862352BFC560000649094 /* LiveGamesModel.swift in Sources */, D113300D2BE5952B00F6DF30 /* BracketGameCell.swift in Sources */, D13EEA582BF9903B00F61B51 /* UpdateRegionsView.swift in Sources */, D1BF49392BA805AA00A00669 /* AppConstants.swift in Sources */, @@ -690,7 +703,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D16862332BFC450B00649094 /* foam_madness_sim_custom_bracket_test.swift in Sources */, D1A67BEF29BD6E4D00E1D55B /* foam_madness_teams_file_test.swift in Sources */, + D168622F2BFC40F700649094 /* foam_madness_sim_existing_bracket_test.swift in Sources */, + D16862312BFC41F000649094 /* CoreDataTestStack.swift in Sources */, D1A67BED29BD631D00E1D55B /* foam_madness_probability_file_test.swift in Sources */, D1A67BE629BD48E200E1D55B /* foam_madness_bracket_file_tests.swift in Sources */, ); @@ -722,6 +738,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -786,6 +803,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; diff --git a/foam-madness/Controller/AppConstants.swift b/foam-madness/Controller/AppConstants.swift index b793346..08d5910 100644 --- a/foam-madness/Controller/AppConstants.swift +++ b/foam-madness/Controller/AppConstants.swift @@ -9,4 +9,5 @@ struct AppConstants { static let defaultShotsPerRound = 10 static let defaultUseBracketView = false + static let refreshDebounceSeconds: Double = 20.0 } diff --git a/foam-madness/Model/Live Games API/LiveGamesModel.swift b/foam-madness/Model/Live Games API/LiveGamesModel.swift new file mode 100644 index 0000000..ccb07ae --- /dev/null +++ b/foam-madness/Model/Live Games API/LiveGamesModel.swift @@ -0,0 +1,53 @@ +// +// LiveGamesModel.swift +// foam-madness +// +// Created by Michael Virgo on 5/20/24. +// Copyright © 2024 mvirgo. All rights reserved. +// + +import SwiftUI + +class LiveGamesModel: ObservableObject { + @Published var liveGames: [Event] = [] + @Published var loading = false + private var lastRefreshTime: Date? + + init() { + refreshData() + } + + func refreshData() { + let now = Date() + if let lastRefreshTime = lastRefreshTime, now.timeIntervalSince(lastRefreshTime) < AppConstants.refreshDebounceSeconds { + // Skip if within the debounce delay + return + } + lastRefreshTime = now + loadGames() + } + + private func loadGames() { + loading = true + liveGames = [] + APIClient.getScores(url: APIClient.Endpoints.getNCAAMScores.url, completion: handleLiveGameScores(response:error:)) + APIClient.getScores(url: APIClient.Endpoints.getNCAAWScores.url, completion: handleLiveGameScores(response:error:)) + APIClient.getScores(url: APIClient.Endpoints.getNBAScores.url, completion: handleLiveGameScores(response:error:)) + APIClient.getScores(url: APIClient.Endpoints.getWNBAScores.url, completion: handleLiveGameScores(response:error:)) + } + + private func handleLiveGameScores(response: LiveGamesResponse?, error: Error?) { + if let error = error { + print(error.localizedDescription) + } else if let response = response { + // Add events to liveGames array + for (_, var game) in response.events.enumerated() { + // Note that there will only be one league in NCAA or (W)NBA APIs + // Add game + game.league = response.leagues[0].abbreviation + liveGames.append(game) + } + } + loading = false + } +} diff --git a/foam-madness/View/Live/LiveGamesView.swift b/foam-madness/View/Live/LiveGamesView.swift index 75f6b5c..27303d8 100644 --- a/foam-madness/View/Live/LiveGamesView.swift +++ b/foam-madness/View/Live/LiveGamesView.swift @@ -9,60 +9,55 @@ import SwiftUI struct LiveGamesView: View { - @State var liveGames = [Event]() - @State var loading = false - let cellPadding = 4.0 - let rowCells = 3.0 + @StateObject private var liveGamesModel = LiveGamesModel() + @State var filter = "" + private let cellPadding = 4.0 + private let rowCells = 3.0 var body: some View { GeometryReader { geometry in let maxWidth = (geometry.size.width - cellPadding * rowCells) / rowCells + let filteredGames = + filter == "" + ? liveGamesModel.liveGames + : liveGamesModel.liveGames.filter({ $0.league == filter }) - if (liveGames.count > 0) { - ScrollView { - LazyVGrid(columns: [GridItem(.flexible()),GridItem(.flexible()),GridItem(.flexible())], spacing: cellPadding) { - ForEach(liveGames) - { game in - LiveGamesCell(game: game) - .frame(width: maxWidth, height: maxWidth) - .cornerRadius(5) - } - }.padding(cellPadding) + VStack { + Picker("", selection: $filter) { + Text("All Leagues").tag("") + Text("Mens's NCAA").tag("NCAAM") + Text("Women's NCAA").tag("NCAAW") + Text("NBA").tag("NBA") + Text("WNBA").tag("WNBA") + }.accentColor(commonBlue) + if (filteredGames.count > 0) { + ScrollView { + LazyVGrid(columns: [GridItem(.flexible()),GridItem(.flexible()),GridItem(.flexible())], spacing: cellPadding) { + ForEach(filteredGames) + { game in + LiveGamesCell(game: game) + .frame(width: maxWidth, height: maxWidth) + .cornerRadius(5) + } + }.padding(cellPadding) + } + } else { + VStack { + Text(liveGamesModel.loading ? "Loading games..." : "No games found.").font(.largeTitle) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - } else { - VStack { - Text(loading ? "Loading games..." : "No games found.").font(.largeTitle) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) } } .navigationTitle("Live Game Scores") - .onAppear { - loadGames() - } - } - - private func loadGames() { - loading = true - APIClient.getScores(url: APIClient.Endpoints.getNCAAMScores.url, completion: handleLiveGameScores(response:error:)) - APIClient.getScores(url: APIClient.Endpoints.getNCAAWScores.url, completion: handleLiveGameScores(response:error:)) - APIClient.getScores(url: APIClient.Endpoints.getNBAScores.url, completion: handleLiveGameScores(response:error:)) - APIClient.getScores(url: APIClient.Endpoints.getWNBAScores.url, completion: handleLiveGameScores(response:error:)) - } - - private func handleLiveGameScores(response: LiveGamesResponse?, error: Error?) { - if let error = error { - print(error.localizedDescription) - } else if let response = response { - // Add events to liveGames array - for (_, var game) in response.events.enumerated() { - // Note that there will only be one league in NCAA or (W)NBA APIs - // Add game - game.league = response.leagues[0].abbreviation - liveGames.append(game) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { liveGamesModel.refreshData() }, label: { + Label("Refresh", systemImage: "arrow.clockwise") + }) } } - loading = false } } diff --git a/foam-madness/View/Tournament/TournamentGamesView/BracketView/BracketGameCell.swift b/foam-madness/View/Tournament/TournamentGamesView/BracketView/BracketGameCell.swift index 02b755d..1148851 100644 --- a/foam-madness/View/Tournament/TournamentGamesView/BracketView/BracketGameCell.swift +++ b/foam-madness/View/Tournament/TournamentGamesView/BracketView/BracketGameCell.swift @@ -46,13 +46,9 @@ struct BracketGameCell: View { var getLinkLabel: some View { return ZStack(alignment: .center) { VStack(alignment: .leading, spacing: 1) { - Text(team1Text) - .frame(minWidth: 140, alignment: .leading) - .padding([.leading, .trailing]) - Text(team2Text) - .frame(minWidth: 140, alignment: .leading) + getTeamTextStyle(team1Text) + getTeamTextStyle(team2Text) .padding([.top], spacing) - .padding([.leading, .trailing]) .padding([.bottom], 5) .border(width: 5, edges: [.top, .bottom, .trailing], color: commonBlue) } @@ -66,6 +62,13 @@ struct BracketGameCell: View { } } } + + private func getTeamTextStyle(_ teamText: String) -> some View { + Text(teamText) + .foregroundColor(teamText == "Pending" ? .secondary : .primary) + .frame(minWidth: 140, alignment: .leading) + .padding([.leading, .trailing]) + } } // 3-sided edge code below from: https://stackoverflow.com/a/58632759 diff --git a/foam-madness/View/Tournament/TournamentGamesView/BracketView/BracketWinnerLine.swift b/foam-madness/View/Tournament/TournamentGamesView/BracketView/BracketWinnerLine.swift index 184fe0b..6b5619f 100644 --- a/foam-madness/View/Tournament/TournamentGamesView/BracketView/BracketWinnerLine.swift +++ b/foam-madness/View/Tournament/TournamentGamesView/BracketView/BracketWinnerLine.swift @@ -13,12 +13,15 @@ struct BracketWinnerLine: View { @Binding var maxRoundForRegion: Int var body: some View { + let isPending = winnerName == "" + VStack { // Skip for First Four if maxRoundForRegion == 0 { EmptyView() } else { - Text(winnerName == "" ? "Pending" : winnerName) + Text(isPending ? "Pending" : winnerName) + .foregroundColor(isPending ? .secondary : .primary) .frame(minWidth: 140, alignment: .leading) .padding([.leading, .trailing]) .padding([.bottom], 5)