diff --git a/League.Demo/Configuration/Tenant.Default.Development.config b/League.Demo/Configuration/Tenant.Default.Development.config index ecd1e38a..e3813c5f 100644 --- a/League.Demo/Configuration/Tenant.Default.Development.config +++ b/League.Demo/Configuration/Tenant.Default.Development.config @@ -169,5 +169,10 @@ True + + + + None + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.Default.Production.config b/League.Demo/Configuration/Tenant.Default.Production.config index d55ae5a7..94236eae 100644 --- a/League.Demo/Configuration/Tenant.Default.Production.config +++ b/League.Demo/Configuration/Tenant.Default.Production.config @@ -168,5 +168,10 @@ True + + + + None + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.OtherOrg.Development.config b/League.Demo/Configuration/Tenant.OtherOrg.Development.config index e9b3b1d5..25b639f4 100644 --- a/League.Demo/Configuration/Tenant.OtherOrg.Development.config +++ b/League.Demo/Configuration/Tenant.OtherOrg.Development.config @@ -169,5 +169,10 @@ True + + + + Home + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.OtherOrg.Production.config b/League.Demo/Configuration/Tenant.OtherOrg.Production.config index 923bf351..6218a9a4 100644 --- a/League.Demo/Configuration/Tenant.OtherOrg.Production.config +++ b/League.Demo/Configuration/Tenant.OtherOrg.Production.config @@ -168,5 +168,10 @@ True + + + + Home + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.TestOrg.Development.config b/League.Demo/Configuration/Tenant.TestOrg.Development.config index fc4e95ce..d0997c12 100644 --- a/League.Demo/Configuration/Tenant.TestOrg.Development.config +++ b/League.Demo/Configuration/Tenant.TestOrg.Development.config @@ -169,5 +169,10 @@ True + + + + Home + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.TestOrg.Production.config b/League.Demo/Configuration/Tenant.TestOrg.Production.config index 0a499251..f962800b 100644 --- a/League.Demo/Configuration/Tenant.TestOrg.Production.config +++ b/League.Demo/Configuration/Tenant.TestOrg.Production.config @@ -169,5 +169,10 @@ True + + + + Home + \ No newline at end of file diff --git a/League.Tests/Identity/RoleStoreTests.cs b/League.Tests/Identity/RoleStoreTests.cs index 310188c8..26dd8309 100644 --- a/League.Tests/Identity/RoleStoreTests.cs +++ b/League.Tests/Identity/RoleStoreTests.cs @@ -17,7 +17,7 @@ namespace League.Tests.Identity; public class RoleStoreTests { private readonly UnitTestHelpers _uth = new(); - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly RoleStore _roleStore; private readonly UserStore _userStore; diff --git a/League.Tests/Identity/UserAuthenticationTokenStoreTests.cs b/League.Tests/Identity/UserAuthenticationTokenStoreTests.cs index f80b503e..1faafaa9 100644 --- a/League.Tests/Identity/UserAuthenticationTokenStoreTests.cs +++ b/League.Tests/Identity/UserAuthenticationTokenStoreTests.cs @@ -16,7 +16,7 @@ namespace League.Tests.Identity; public class UserAuthenticationTokenStoreTests { private readonly UnitTestHelpers _uth = new(); - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly UserStore _store; public UserAuthenticationTokenStoreTests() diff --git a/League.Tests/Identity/UserClaimStoreTests.cs b/League.Tests/Identity/UserClaimStoreTests.cs index f6606e64..cb3abaab 100644 --- a/League.Tests/Identity/UserClaimStoreTests.cs +++ b/League.Tests/Identity/UserClaimStoreTests.cs @@ -16,7 +16,7 @@ namespace League.Tests.Identity; public class UserClaimStoreTests { private readonly UnitTestHelpers _uth = new(); - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private ApplicationUser _user = new(); private readonly UserStore _store; private TeamEntity _team = new(); diff --git a/League.Tests/Identity/UserLoginStoreTests.cs b/League.Tests/Identity/UserLoginStoreTests.cs index bcc23f5a..4d913c7e 100644 --- a/League.Tests/Identity/UserLoginStoreTests.cs +++ b/League.Tests/Identity/UserLoginStoreTests.cs @@ -16,7 +16,7 @@ namespace League.Tests.Identity; public class UserLoginStoreTests { private readonly UnitTestHelpers _uth = new(); - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly UserStore _store; public UserLoginStoreTests() diff --git a/League.Tests/Identity/UserRoleStoreTests.cs b/League.Tests/Identity/UserRoleStoreTests.cs index 0cca5976..39f44d31 100644 --- a/League.Tests/Identity/UserRoleStoreTests.cs +++ b/League.Tests/Identity/UserRoleStoreTests.cs @@ -17,7 +17,7 @@ namespace League.Tests.Identity; public class UserRoleStoreTests { private readonly UnitTestHelpers _uth = new(); - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly UserStore _userStore; private ApplicationUser _user = new(); diff --git a/League.Tests/Identity/UserStoreTests.cs b/League.Tests/Identity/UserStoreTests.cs index 0640d3fb..9e16b5e6 100644 --- a/League.Tests/Identity/UserStoreTests.cs +++ b/League.Tests/Identity/UserStoreTests.cs @@ -16,7 +16,7 @@ namespace League.Tests.Identity; public class UserStoreTests { private readonly UnitTestHelpers _uth = new(); - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly UserStore _store; private readonly RoleStore _roleStore; diff --git a/League/Components/RoundSelector.cs b/League/Components/RoundSelector.cs index f49a278a..d1d31b10 100644 --- a/League/Components/RoundSelector.cs +++ b/League/Components/RoundSelector.cs @@ -7,7 +7,7 @@ namespace League.Components; public class RoundSelector : ViewComponent { private readonly ITenantContext _tenantContext; - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly ILogger _logger; public RoundSelector(ITenantContext tenantContext, ILogger logger) diff --git a/League/Components/VenueSelector.cs b/League/Components/VenueSelector.cs index 116d0aa6..2511ac47 100644 --- a/League/Components/VenueSelector.cs +++ b/League/Components/VenueSelector.cs @@ -6,7 +6,7 @@ namespace League.Components; public class VenueSelector : ViewComponent { - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly ILogger _logger; public VenueSelector(ITenantContext tenantContext, ILogger logger) diff --git a/League/Controllers/Contact.cs b/League/Controllers/Contact.cs index 1817f68f..8a566ea0 100644 --- a/League/Controllers/Contact.cs +++ b/League/Controllers/Contact.cs @@ -12,7 +12,7 @@ namespace League.Controllers; public class Contact : AbstractController { #pragma warning disable IDE0052 // Remove unread private members - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly TenantStore _tenantStore; #pragma warning restore IDE0052 // Remove unread private members private readonly ITenantContext _tenantContext; diff --git a/League/Controllers/Map.cs b/League/Controllers/Map.cs index b2dd4339..b2fbc3ce 100644 --- a/League/Controllers/Map.cs +++ b/League/Controllers/Map.cs @@ -13,7 +13,7 @@ public class Map : AbstractController private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly ITenantContext _tenantContext; - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly IStringLocalizer _localizer; private readonly GoogleConfiguration _googleConfig; diff --git a/League/Controllers/Match.cs b/League/Controllers/Match.cs index 06140afd..676e734c 100644 --- a/League/Controllers/Match.cs +++ b/League/Controllers/Match.cs @@ -26,7 +26,7 @@ namespace League.Controllers; public class Match : AbstractController { private readonly ITenantContext _tenantContext; - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly IStringLocalizer _localizer; private readonly IAuthorizationService _authorizationService; private readonly Axuno.Tools.DateAndTime.TimeZoneConverter _timeZoneConverter; diff --git a/League/Controllers/Ranking.cs b/League/Controllers/Ranking.cs index 613df1ab..89536269 100644 --- a/League/Controllers/Ranking.cs +++ b/League/Controllers/Ranking.cs @@ -20,7 +20,7 @@ public class Ranking : AbstractController { private readonly ITenantContext _tenantContext; private readonly IWebHostEnvironment _webHostEnvironment; - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly ILogger _logger; private readonly IMemoryCache _memoryCache; diff --git a/League/Controllers/Team.cs b/League/Controllers/Team.cs index 8237c505..f0072de1 100644 --- a/League/Controllers/Team.cs +++ b/League/Controllers/Team.cs @@ -20,7 +20,7 @@ namespace League.Controllers; public class Team : AbstractController { private readonly ITenantContext _tenantContext; - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly IStringLocalizer _localizer; private readonly IAuthorizationService _authorizationService; private readonly ILogger _logger; diff --git a/League/Controllers/TeamApplication.cs b/League/Controllers/TeamApplication.cs index 8f65c837..d29806b7 100644 --- a/League/Controllers/TeamApplication.cs +++ b/League/Controllers/TeamApplication.cs @@ -29,7 +29,7 @@ namespace League.Controllers; public class TeamApplication : AbstractController { private readonly ITenantContext _tenantContext; - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly IAuthorizationService _authorizationService; private readonly ILogger _logger; private readonly Axuno.Tools.DateAndTime.TimeZoneConverter _timeZoneConverter; diff --git a/League/Controllers/Venue.cs b/League/Controllers/Venue.cs index a2b8b453..ce62c2fb 100644 --- a/League/Controllers/Venue.cs +++ b/League/Controllers/Venue.cs @@ -20,7 +20,7 @@ namespace League.Controllers; public class Venue : AbstractController { private readonly ITenantContext _tenantContext; - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly IAuthorizationService _authorizationService; private readonly IStringLocalizer _localizer; private readonly RegionInfo _regionInfo; diff --git a/League/Identity/RoleStore.cs b/League/Identity/RoleStore.cs index 98482d39..2709f239 100644 --- a/League/Identity/RoleStore.cs +++ b/League/Identity/RoleStore.cs @@ -10,7 +10,7 @@ namespace League.Identity; /// public class RoleStore : IRoleStore, IRoleClaimStore { - private readonly TournamentManager.MultiTenancy.AppDb _appDb; + private readonly IAppDb _appDb; private readonly ILogger _logger; private readonly ILookupNormalizer _keyNormalizer; private readonly IdentityErrorDescriber _identityErrorDescriber; diff --git a/League/Identity/UserStore.cs b/League/Identity/UserStore.cs index 3717dfaf..eef66e72 100644 --- a/League/Identity/UserStore.cs +++ b/League/Identity/UserStore.cs @@ -12,7 +12,7 @@ namespace League.Identity; /// public class UserStore : IUserStore, IUserEmailStore, IUserPhoneNumberStore, IUserPasswordStore, IUserRoleStore, IUserClaimStore, IUserSecurityStampStore, IUserLoginStore, IUserAuthenticationTokenStore, IUserLockoutStore { - private readonly TournamentManager.MultiTenancy.AppDb _appDb; + private readonly IAppDb _appDb; private readonly ILogger _logger; private readonly ILookupNormalizer _keyNormalizer; private readonly IdentityErrorDescriber _identityErrorDescriber; diff --git a/TournamentManager/TournamentManager.Tests/ExtensionMethods/MatchEntityListExtensionsTests.cs b/TournamentManager/TournamentManager.Tests/ExtensionMethods/MatchEntityListExtensionsTests.cs new file mode 100644 index 00000000..236e410d --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/ExtensionMethods/MatchEntityListExtensionsTests.cs @@ -0,0 +1,72 @@ +using NUnit.Framework; +using FluentAssertions; +using TournamentManager.DAL.EntityClasses; +using TournamentManager.ExtensionMethods; + +namespace TournamentManager.Tests.ExtensionMethods; + +[TestFixture] +public class MatchEntityListExtensionTests +{ + private readonly List _matches = new() + { + new MatchEntity {Id = 1, HomeTeamId = 1, GuestTeamId = 2, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 2, HomeTeamId = 1, GuestTeamId = 3, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 3, HomeTeamId = 2, GuestTeamId = 3, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 4, HomeTeamId = 1, GuestTeamId = 4, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 5, HomeTeamId = 4, GuestTeamId = 2, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 6, HomeTeamId = 3, GuestTeamId = 4, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 7, HomeTeamId = 1, GuestTeamId = 5, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 8, HomeTeamId = 2, GuestTeamId = 5, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 9, HomeTeamId = 3, GuestTeamId = 5, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 10, HomeTeamId = 4, GuestTeamId = 5, PlannedStart = DateTime.UtcNow.AddDays(-1), PlannedEnd = DateTime.UtcNow.AddDays(-1).AddHours(2), VenueId = 1}, + new MatchEntity {Id = 11, HomeTeamId = 10, GuestTeamId = 11, PlannedStart = null, PlannedEnd = null, VenueId = null}, + new MatchEntity {Id = 12, HomeTeamId = 12, GuestTeamId = 10, PlannedStart = null, PlannedEnd = DateTime.UtcNow, VenueId = null}, + new MatchEntity {Id = 13, HomeTeamId = 11, GuestTeamId = 13, PlannedStart = DateTime.UtcNow, PlannedEnd = null, VenueId = null} + }; + + [TestCase(1, -1, 0, false, 0)] + [TestCase(1, -1, 5, false, 3)] + [TestCase(1, 2, 5, false, 5)] + [TestCase(10, 13, 12, false, 0)] + [TestCase(10, 13, 12, true, 2)] + public void Previous_Matches_Relative_To_Index_Should_Be_Found(long team1, long team2, int startIndex, bool includeUndefined, int expected) + { + var teamIds = new[] {team1, team2}; + var matches = _matches.GetPreviousMatches(startIndex, teamIds, includeUndefined).ToList(); + + Assert.That(matches.Count, Is.EqualTo(expected)); + + } + + [TestCase(-1)] + [TestCase(13)] + public void Invalid_StartIndex_For_Previous_Should_Throw(int startIndex) + { + var teamIds = new long[] { 1 }; + + Assert.That(() => _matches.GetPreviousMatches(startIndex, teamIds, true).ToList(), Throws.TypeOf()); + } + + [TestCase(1, -1, 12, false, 0)] + [TestCase(1, -1, 0, false, 3)] + [TestCase(1, 2, 1, false, 5)] + [TestCase(10, 13, 0, false, 0)] + [TestCase(10, 13, 0, true, 3)] + public void Next_Matches_Relative_To_Index_Should_Be_Found(long team1, long team2, int startIndex, bool includeUndefined, int expected) + { + var teamIds = new[] { team1, team2 }; + var matches = _matches.GetNextMatches(startIndex, teamIds, includeUndefined).ToList(); + + Assert.That(matches.Count, Is.EqualTo(expected)); + } + + [TestCase(-1)] + [TestCase(13)] + public void Invalid_StartIndex_For_Next_Should_Throw(int startIndex) + { + var teamIds = new long[] { 1 }; + + Assert.That(() => _matches.GetNextMatches(startIndex, teamIds, true).ToList(), Throws.TypeOf()); + } +} diff --git a/TournamentManager/TournamentManager.Tests/ModelValidators/FixtureValidatorTests.cs b/TournamentManager/TournamentManager.Tests/ModelValidators/FixtureValidatorTests.cs index 4a1d76c2..ac5343d4 100644 --- a/TournamentManager/TournamentManager.Tests/ModelValidators/FixtureValidatorTests.cs +++ b/TournamentManager/TournamentManager.Tests/ModelValidators/FixtureValidatorTests.cs @@ -16,9 +16,6 @@ namespace TournamentManager.Tests.ModelValidators; public class FixtureValidatorTests { private (ITenantContext TenantConext, Axuno.Tools.DateAndTime.TimeZoneConverter TimeZoneConverter, PlannedMatchRow PlannedMatch) _data; -#pragma warning disable IDE0052 // Remove unread private members - private readonly AppDb _appDb; // mocked in CTOR -#pragma warning restore IDE0052 // Remove unread private members private readonly CultureInfo _culture = CultureInfo.GetCultureInfo("en-US"); private const string ExcludedDateReason = "Unit-Test"; @@ -122,14 +119,6 @@ public FixtureValidatorTests() tenantContextMock.SetupDbContext(dbContextMock); _data.TenantConext = tenantContextMock.Object; - _appDb = appDbMock.Object; - - //var teamIds = orgCtxMock.Object.AppDb.MatchRepository.AreTeamsBusyAsync(new MatchEntity {Id = 1, HomeTeamId = 11, GuestTeamId = 22}, false, CancellationToken.None).Result; - //var matchrow = venueRepoMock.Object.GetOccupyingMatchesAsync(1, new DateTimePeriod(null, default(DateTime?)), 2, CancellationToken.None).Result; - //var venue = appDbMock.Object.VenueRepository.GetVenueById(2); - //var isValid = orgCtxMock.Object.AppDb.VenueRepository.IsValidVenueId(22).Result; - //isValid = orgCtxMock.Object.AppDb.VenueRepository.IsValidVenueId(21).Result; - #endregion } diff --git a/TournamentManager/TournamentManager.Tests/ModelValidators/MatchResultValidatorTests.cs b/TournamentManager/TournamentManager.Tests/ModelValidators/MatchResultValidatorTests.cs index c25adb05..a1b90f9e 100644 --- a/TournamentManager/TournamentManager.Tests/ModelValidators/MatchResultValidatorTests.cs +++ b/TournamentManager/TournamentManager.Tests/ModelValidators/MatchResultValidatorTests.cs @@ -12,9 +12,6 @@ namespace TournamentManager.Tests.ModelValidators; public class MatchResultValidatorTests { private readonly (ITenantContext TenantContext, Axuno.Tools.DateAndTime.TimeZoneConverter TimeZoneConverter, (MatchRuleEntity matchRule, SetRuleEntity setRule)) _data; -#pragma warning disable IDE0052 // Remove unread private members - private readonly AppDb _appDb; // mocked in CTOR -#pragma warning restore IDE0052 // Remove unread private members public MatchResultValidatorTests() { @@ -53,8 +50,6 @@ public MatchResultValidatorTests() ); appDbMock.Setup(a => a.RoundRepository).Returns(roundRepoMock.Object); - _appDb = appDbMock.Object; - var dbContextMock = TestMocks.GetDbContextMock(); dbContextMock.SetupAppDb(appDbMock); diff --git a/TournamentManager/TournamentManager.Tests/ModelValidators/TeamValidatorTests.cs b/TournamentManager/TournamentManager.Tests/ModelValidators/TeamValidatorTests.cs index 6f656fa9..09550e5c 100644 --- a/TournamentManager/TournamentManager.Tests/ModelValidators/TeamValidatorTests.cs +++ b/TournamentManager/TournamentManager.Tests/ModelValidators/TeamValidatorTests.cs @@ -17,9 +17,6 @@ namespace TournamentManager.Tests.ModelValidators; public class TeamValidatorTests { private readonly ITenantContext _tenantContext; -#pragma warning disable IDE0052 // Remove unread private members - private readonly AppDb _appDb; // mocked in CTOR -#pragma warning restore IDE0052 // Remove unread private members public TeamValidatorTests() { @@ -54,8 +51,6 @@ public TeamValidatorTests() _tenantContext = tenantContextMock.Object; - _appDb = appDbMock.Object; - #endregion } diff --git a/TournamentManager/TournamentManager.Tests/ModelValidators/TeamVenueValidatorTests.cs b/TournamentManager/TournamentManager.Tests/ModelValidators/TeamVenueValidatorTests.cs index 52f35421..22ad8398 100644 --- a/TournamentManager/TournamentManager.Tests/ModelValidators/TeamVenueValidatorTests.cs +++ b/TournamentManager/TournamentManager.Tests/ModelValidators/TeamVenueValidatorTests.cs @@ -13,9 +13,6 @@ namespace TournamentManager.Tests.ModelValidators; public class TeamVenueValidatorTests { private readonly ITenantContext _tenantContext; -#pragma warning disable IDE0052 // Remove unread private members - private readonly AppDb _appDb; // mocked in CTOR -#pragma warning restore IDE0052 // Remove unread private members public TeamVenueValidatorTests() { @@ -50,8 +47,6 @@ public TeamVenueValidatorTests() _tenantContext = tenantContextMock.Object; - _appDb = appDbMock.Object; - #endregion } diff --git a/TournamentManager/TournamentManager.Tests/Plan/AvailableMatchDatesTests.cs b/TournamentManager/TournamentManager.Tests/Plan/AvailableMatchDatesTests.cs new file mode 100644 index 00000000..7f5c55df --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/Plan/AvailableMatchDatesTests.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using TournamentManager.DAL.EntityClasses; +using TournamentManager.DAL.HelperClasses; +using TournamentManager.Data; +using TournamentManager.Plan; +using TournamentManager.Tests.TestComponents; +using Moq; + +namespace TournamentManager.Tests.Plan; + +[TestFixture] +internal class AvailableMatchDatesTests +{ + [Test] + public void Generate_Available_Dates_Should_Succeed() + { + var availableDates = GetAvailableMatchDatesInstance(); + var tournament = ScheduleHelper.GetTournament(); + var tournamentLeg = tournament.Rounds.First().RoundLegs.First(); + var matches = new EntityCollection(); + + Assert.Multiple(() => + { + Assert.That(del: async () => await availableDates.GenerateNewAsync(tournament.Rounds[0], matches, CancellationToken.None), Throws.Nothing); + Assert.That(availableDates.GetGeneratedAndManualAvailableMatchDateDays(tournamentLeg).Count, Is.EqualTo(87)); + Assert.That(availableDates.GetGeneratedAndManualAvailableMatchDates(1, new DateTimePeriod(tournamentLeg.StartDateTime, tournamentLeg.EndDateTime), null).Count, Is.EqualTo(17)); + }); + } + + [Test] + public async Task Clearing_Available_Dates_Should_Succeed() + { + var availableDates = GetAvailableMatchDatesInstance(); + Assert.That(await availableDates.ClearAsync(MatchDateClearOption.All, CancellationToken.None), Is.EqualTo(0)); + } + + private AvailableMatchDates GetAvailableMatchDatesInstance() + { + var tenantContextMock = TestMocks.GetTenantContextMock(); + var appDbMock = TestMocks.GetAppDbMock(); + + #region ** AvailableMatchDateRepository mocks setup ** + + var availableMatchDatesRepoMock = TestMocks.GetRepo(); + availableMatchDatesRepoMock.Setup(rep => + rep.GetAvailableMatchDatesAsync(It.IsAny(), It.IsAny())) + .Returns((long tournamentId, CancellationToken cancellationToken) => + { + var availableMatchDates = new EntityCollection(); + return Task.FromResult(availableMatchDates); + }); + availableMatchDatesRepoMock.Setup(rep => + rep.ClearAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((long tournamentId, MatchDateClearOption clear, CancellationToken cancellationToken) => Task.FromResult(0)); + appDbMock.Setup(a => a.AvailableMatchDateRepository).Returns(availableMatchDatesRepoMock.Object); + + #endregion + + #region ** ExcludedMatchDateRepository mocks setup ** + + var excludedMatchDatesMock = TestMocks.GetRepo(); + excludedMatchDatesMock.Setup(rep => + rep.GetExcludedMatchDatesAsync(It.IsAny(), It.IsAny())) + .Returns((long tournamentId, CancellationToken cancellationToken) => + { + var excludedMatchDates = new EntityCollection(); + return Task.FromResult(excludedMatchDates); + }); + appDbMock.Setup(a => a.ExcludedMatchDateRepository).Returns(excludedMatchDatesMock.Object); + + #endregion + + // Build complete TenantContext mock + var dbContextMock = TestMocks.GetDbContextMock(); + dbContextMock.SetupAppDb(appDbMock); + tenantContextMock.SetupDbContext(dbContextMock); + + // Create AvailableMatchDates instance + var logger = NullLogger.Instance; + var tzConverter = new Axuno.Tools.DateAndTime.TimeZoneConverter( + new NodaTime.TimeZones.DateTimeZoneCache(NodaTime.TimeZones.TzdbDateTimeZoneSource.Default), "Europe/Berlin", + CultureInfo.CurrentCulture, + NodaTime.TimeZones.Resolvers.LenientResolver); + var availableMatchDates = new AvailableMatchDates(tenantContextMock.Object, tzConverter, logger); + return availableMatchDates; + } +} + diff --git a/TournamentManager/TournamentManager.Tests/Plan/MatchCreatorTests.cs b/TournamentManager/TournamentManager.Tests/Plan/MatchCreatorTests.cs new file mode 100644 index 00000000..1da3c1cc --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/Plan/MatchCreatorTests.cs @@ -0,0 +1,165 @@ +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using TournamentManager.MultiTenancy; +using TournamentManager.Plan; + +namespace TournamentManager.Tests.Plan; + +[TestFixture] +internal class MatchCreatorTests +{ + [Test] + public void InstantiateMatchCreatorWithIncompatibleTypesShouldThrow() + { + var tenantContext = new TenantContext(); + + // long is not assignable from char + Assert.Throws(() => + _ = new MatchCreator(tenantContext, NullLogger>.Instance)); + } + + [TestCase(RefereeType.None)] + [TestCase(RefereeType.Home)] + [TestCase(RefereeType.Guest)] + [TestCase(RefereeType.OtherFromRound)] + public void NumberOfMatchesOfFirstAndReturnLegShouldBeEqual(RefereeType refereeType) + { + var participants = GetParticipants(6); + var tenantContext = new TenantContext(); + tenantContext.TournamentContext.RefereeRuleSet.RefereeType = refereeType; + + var matchCreator = new MatchCreator(tenantContext, NullLogger>.Instance); + var combinationsFirstLeg = + matchCreator.SetParticipants(participants).GetCombinations(LegType.First); + var combinationsReturnLeg = + matchCreator.SetParticipants(participants).GetCombinations(LegType.Return); + + Assert.That(combinationsFirstLeg.Count, Is.EqualTo(combinationsReturnLeg.Count)); + } + + + [TestCase(RefereeType.None)] + [TestCase(RefereeType.Home)] + [TestCase(RefereeType.Guest)] + [TestCase(RefereeType.OtherFromRound)] + public void CreateMatchesWithRefereeType(RefereeType refereeType) + { + var participants = GetParticipants(5); + + var tenantContext = new TenantContext(); + tenantContext.TournamentContext.RefereeRuleSet.RefereeType = refereeType; + + // build up match combinations for the teams of round + var matchCreator = new MatchCreator(tenantContext, NullLogger>.Instance); + + var combinations = + matchCreator.SetParticipants(participants).GetCombinations(LegType.First); + + var firstCombination = combinations.First(); + var expectedReferee = refereeType switch + { + RefereeType.None => default(long?), + RefereeType.Home => firstCombination.Home, + RefereeType.Guest => firstCombination.Guest, + RefereeType.OtherFromRound => 1, + _ => throw new ArgumentOutOfRangeException(nameof(refereeType)) + }; + + Assert.That(combinations.Count, Is.EqualTo(10)); + Assert.That(matchCreator.CombinationsPerLeg, Is.EqualTo(4)); + Assert.That(firstCombination.Referee, Is.EqualTo(expectedReferee)); + } + + [Test] + public void OtherOfRoundRefereeShouldNeverBeHomeOrGuest() + { + var participants = GetParticipants(5); + var tenantContext = new TenantContext(); + tenantContext.TournamentContext.RefereeRuleSet.RefereeType = RefereeType.OtherFromRound; + + var matchCreator = new MatchCreator(tenantContext, NullLogger>.Instance); + var combinations = + matchCreator.SetParticipants(participants).GetCombinations(LegType.First) + .Union(matchCreator.SetParticipants(participants).GetCombinations(LegType.Return)); + + Assert.That(combinations.All(c => c.Home != c.Referee && c.Guest != c.Referee), Is.True, "Referee is never home or guest"); + } + + [Test] + public void CreateMatchesWithUndefinedRefereeTypeShouldThrow() + { + var participants = GetParticipants(5); + var tenantContext = new TenantContext(); + tenantContext.TournamentContext.RefereeRuleSet.RefereeType = (RefereeType) 12345; + + var matchCreator = new MatchCreator(tenantContext, NullLogger>.Instance); + + Assert.Throws(() => + { + _ = matchCreator.SetParticipants(participants).GetCombinations(LegType.First); + }); + } + + [Test] + public void SwappingHomeAndGuestInParticipantCombinationShouldSucceed() + { + var matchCreator = GetMatchCreator(5, RefereeType.Home); + var combination = matchCreator.GetCombinations(LegType.First).First(); + + var (home, guest) = (combination.Home, combination.Guest); + var stringBeforeSwap = combination.ToString(); + combination.SwapHomeGuest(); + + Assert.Multiple(() => + { + Assert.That(combination.Home, Is.EqualTo(guest)); + Assert.That(combination.Guest, Is.EqualTo(home)); + Assert.That(combination.ToString(), Is.Not.EqualTo(stringBeforeSwap)); + }); + } + + [Test] + public void NumberOfCombinationsAndTurns() + { + const int numOfParticipants = 5; + var matchCreator = GetMatchCreator(numOfParticipants, RefereeType.Home); + var firstLeg = matchCreator.GetCombinations(LegType.First); + var firstTurn = firstLeg.GetCombinations(1); + var allTurns = firstLeg.GetTurns().ToList(); + foreach(var turn in allTurns) + { + firstLeg.TurnDateTimePeriods.Add(turn, null); + } + + Assert.Multiple(() => + { + Assert.That(firstLeg.Count, Is.EqualTo(numOfParticipants * 2)); + Assert.That(firstTurn.Count, Is.EqualTo(2)); + Assert.That(allTurns.Count, Is.EqualTo(numOfParticipants)); + Assert.That(firstLeg.TurnDateTimePeriods.Count, Is.EqualTo(allTurns.Count)); + }); + } + + private static Collection GetParticipants(int numOfParticipants) + { + var participants = new Collection(); + for (var i = 1; i <= numOfParticipants; i++) + { + participants.Add(i); + } + + return participants; + } + + private static MatchCreator GetMatchCreator(int numOfParticipants, RefereeType refereeType) + { + var participants = GetParticipants(numOfParticipants); + var tenantContext = new TenantContext(); + tenantContext.TournamentContext.RefereeRuleSet.RefereeType = refereeType; + + return new MatchCreator(tenantContext, NullLogger>.Instance) + .SetParticipants(participants); + } +} + diff --git a/TournamentManager/TournamentManager.Tests/Plan/MatchSchedulerTests.cs b/TournamentManager/TournamentManager.Tests/Plan/MatchSchedulerTests.cs new file mode 100644 index 00000000..970abd32 --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/Plan/MatchSchedulerTests.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using SD.LLBLGen.Pro.ORMSupportClasses; +using TournamentManager.DAL.EntityClasses; +using TournamentManager.DAL.HelperClasses; +using TournamentManager.Data; +using TournamentManager.Plan; +using TournamentManager.Tests.TestComponents; +using Moq; +using TournamentManager.MultiTenancy; + +namespace TournamentManager.Tests.Plan; + +[TestFixture] +internal class MatchSchedulerTests +{ + private ITenantContext _tenantContext = new TenantContext(); + private readonly TournamentEntity? _tournamentEntityForMatchScheduler = ScheduleHelper.GetTournament(); + + [Test] + public void Generate_Schedule_Should_Succeed() + { + EntityCollection matches = new(); + var scheduler = GetMatchSchedulerInstance(); + scheduler.OnBeforeSave += (sender, fixtures) => matches = fixtures; + var participants = _tournamentEntityForMatchScheduler!.Rounds.First().TeamCollectionViaTeamInRound; + var expectedNumOfMatches = participants.Count * (participants.Count - 1) / 2; + + Assert.Multiple(() => + { + Assert.That(del: async () => await scheduler.ScheduleFixturesForTournament(false, CancellationToken.None), Throws.Nothing); + Assert.That(matches.Count, Is.EqualTo(expectedNumOfMatches)); + Assert.That(matches.All(m => (m.PlannedEnd!.Value - m.PlannedStart!.Value) == _tenantContext.TournamentContext.FixtureRuleSet.PlannedDurationOfMatch), Is.True); + Assert.That(matches.All(m => m.HomeTeamId == m.RefereeId), Is.True); + Assert.That(matches.All(m => m.LegSequenceNo == 1), Is.True); + Assert.That(matches.All(m => m.RoundId == 1), Is.True); + Assert.That(matches.All(m => m.VenueId == participants.First(p => p.Id == m.HomeTeamId).VenueId), Is.True); + }); + } + + private MatchScheduler GetMatchSchedulerInstance() + { + var tournamentMatches = new EntityCollection(); + + var tenantContextMock = TestMocks.GetTenantContextMock(); + var appDbMock = TestMocks.GetAppDbMock(); + + #region ** GenericRepository mocks setup ** + + var genericRepoMock = TestMocks.GetRepo(); + genericRepoMock.Setup(rep => + rep.SaveEntitiesAsync(It.IsAny>(), true, false,It.IsAny())) + .Returns((EntityCollection matches, bool refetchAfterSave, bool recursion, CancellationToken cancellationToken) => + { + // DO NOT add matches to tournamentMatches, because + // the same instance is returned from method GetMatches() of MatchRepository. + // Meaning the matches already exist there. + foreach (var matchEntity in tournamentMatches) + { + matchEntity.IsDirty = false; + matchEntity.IsNew = false; + } + + return Task.CompletedTask; + }); + genericRepoMock.Setup(rep => + rep.DeleteEntitiesDirectlyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Type type, IRelationPredicateBucket bucket, CancellationToken cancellationToken) => + { + if (type == typeof(MatchEntity)) + { + var count = tournamentMatches.Count; + tournamentMatches.Clear(); + return Task.FromResult(count); + } + throw new ArgumentException("Type not supported"); + }); + appDbMock.Setup(a => a.GenericRepository).Returns(genericRepoMock.Object); + + #endregion + + #region ** TournamentRepository mocks setup ** + + var tournamentRepoMock = TestMocks.GetRepo(); + tournamentRepoMock.Setup(rep => + rep.GetTournamentEntityForMatchSchedulerAsync(It.IsAny(), It.IsAny())) + .Returns((long tournamentId, CancellationToken cancellationToken) => Task.FromResult(_tournamentEntityForMatchScheduler)); + appDbMock.Setup(a => a.TournamentRepository).Returns(tournamentRepoMock.Object); + + #endregion + + #region ** MatchRepository mocks setup ** + + var matchRepoMock = TestMocks.GetRepo(); + matchRepoMock.Setup(rep => + rep.AnyCompleteMatchesExistAsync(It.IsAny(), It.IsAny())) + .Returns((long tournamentId, CancellationToken cancellationToken) => Task.FromResult(false)); + matchRepoMock.Setup(rep => + rep.GetMatches(It.IsAny(), It.IsAny())) + .Returns((long tournamentId, CancellationToken cancellationToken) => + { + return Task.FromResult(tournamentMatches); + }); + appDbMock.Setup(a => a.MatchRepository).Returns(matchRepoMock.Object); + + #endregion + + #region ** AvailableMatchDateRepository mocks setup ** + + var availableMatchDatesRepoMock = TestMocks.GetRepo(); + availableMatchDatesRepoMock.Setup(rep => + rep.GetAvailableMatchDatesAsync(It.IsAny(), It.IsAny())) + .Returns((long tournamentId, CancellationToken cancellationToken) => + { + var availableMatchDates = new EntityCollection(); + return Task.FromResult(availableMatchDates); + }); + availableMatchDatesRepoMock.Setup(rep => + rep.ClearAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((long tournamentId, MatchDateClearOption clear, CancellationToken cancellationToken) => Task.FromResult(0)); + appDbMock.Setup(a => a.AvailableMatchDateRepository).Returns(availableMatchDatesRepoMock.Object); + + #endregion + + #region ** ExcludedMatchDateRepository mocks setup ** + + var excludedMatchDatesMock = TestMocks.GetRepo(); + excludedMatchDatesMock.Setup(rep => + rep.GetExcludedMatchDatesAsync(It.IsAny(), It.IsAny())) + .Returns((long tournamentId, CancellationToken cancellationToken) => + { + var excludedMatchDates = new EntityCollection(); + return Task.FromResult(excludedMatchDates); + }); + appDbMock.Setup(a => a.ExcludedMatchDateRepository).Returns(excludedMatchDatesMock.Object); + + #endregion + + // Build complete TenantContext mock + var dbContextMock = TestMocks.GetDbContextMock(); + dbContextMock.SetupAppDb(appDbMock); + tenantContextMock.SetupDbContext(dbContextMock); + + // Create MatchScheduler instance + var logger = NullLogger.Instance; + var tzConverter = new Axuno.Tools.DateAndTime.TimeZoneConverter( + new NodaTime.TimeZones.DateTimeZoneCache(NodaTime.TimeZones.TzdbDateTimeZoneSource.Default), "Europe/Berlin", + CultureInfo.CurrentCulture, + NodaTime.TimeZones.Resolvers.LenientResolver); + + _tenantContext = tenantContextMock.Object; + _tenantContext.TournamentContext.MatchPlanTournamentId = 1; + _tenantContext.TournamentContext.FixtureRuleSet.PlannedDurationOfMatch = TimeSpan.FromHours(2); + _tenantContext.TournamentContext.RefereeRuleSet.RefereeType = RefereeType.Home; + var scheduler = new MatchScheduler(_tenantContext, tzConverter, NullLoggerFactory.Instance); + return scheduler; + } +} + diff --git a/TournamentManager/TournamentManager.Tests/Plan/ScheduleHelper.cs b/TournamentManager/TournamentManager.Tests/Plan/ScheduleHelper.cs new file mode 100644 index 00000000..202d338c --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/Plan/ScheduleHelper.cs @@ -0,0 +1,63 @@ +using NUnit.Framework; +using SD.LLBLGen.Pro.ORMSupportClasses; +using TournamentManager.DAL.EntityClasses; +using TournamentManager.DAL.HelperClasses; + +namespace TournamentManager.Tests.Plan; +internal class ScheduleHelper +{ + internal static TournamentEntity? GetTournamentNullable() + { + var t = GetTournament(); + // Condition will always be true + return t.Fields.Count > 0 ? t : null; + } + + internal static TournamentEntity GetTournament() + { + var teams = new EntityCollection { + { new (1) { Venue = new VenueEntity(1){ IsDirty = false, IsNew = false }, MatchDayOfWeek = 5, MatchTime = new TimeSpan(18, 0, 0) } }, + { new (2) { Venue = new VenueEntity(2){ IsDirty = false, IsNew = false }, MatchDayOfWeek = 4, MatchTime = new TimeSpan(18, 30, 0) } }, + { new (3) { Venue = new VenueEntity(3){ IsDirty = false, IsNew = false }, MatchDayOfWeek = 3, MatchTime = new TimeSpan(19, 0, 0) } }, + { new (4) { Venue = new VenueEntity(4){ IsDirty = false, IsNew = false }, MatchDayOfWeek = 2, MatchTime = new TimeSpan(19, 30, 0) } }, + { new (5) { Venue = new VenueEntity(5){ IsDirty = false, IsNew = false }, MatchDayOfWeek = 1, MatchTime = new TimeSpan(20, 0, 0) } } + }; + foreach (var teamEntity in teams) + { + teamEntity.Fields.State = EntityState.Fetched; + teamEntity.IsNew = teamEntity.IsDirty = false; + } + + var round = new RoundEntity(1) + { + RoundLegs = { new RoundLegEntity { Id = 1, RoundId = 1, SequenceNo = 1, StartDateTime = new DateTime(2024, 1, 1), EndDateTime = new DateTime(2024, 4, 30) } }, IsNew = false, IsDirty = false + }; + + var teamInRounds = new EntityCollection { + new() { Round = round, Team = teams[0], IsNew = false, IsDirty = false }, + new() { Round = round, Team = teams[1], IsNew = false, IsDirty = false }, + new() { Round = round, Team = teams[2], IsNew = false, IsDirty = false }, + new() { Round = round, Team = teams[3], IsNew = false, IsDirty = false }, + new() { Round = round, Team = teams[4], IsNew = false, IsDirty = false } + }; + + round.TeamInRounds.AddRange(teamInRounds); + + foreach (var teamInRound in teamInRounds) + { + teamInRound.Fields.State = EntityState.Fetched; + teamInRound.IsNew = teamInRound.IsDirty = false; + } + + var tournament = new TournamentEntity(1) { IsNew = false, IsDirty = false }; + round.Tournament = tournament; + // Must be set to false, otherwise teams cannot be added to the collection + round.TeamCollectionViaTeamInRound.IsReadOnly = false; + round.TeamCollectionViaTeamInRound.AddRange(teams); + round.Fields.State = EntityState.Fetched; + + tournament.Rounds.Add(round); + + return tournament; + } +} diff --git a/TournamentManager/TournamentManager.Tests/RoundRobin/CustomStruct.cs b/TournamentManager/TournamentManager.Tests/RoundRobin/CustomStruct.cs new file mode 100644 index 00000000..95b533c1 --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/RoundRobin/CustomStruct.cs @@ -0,0 +1,16 @@ +namespace TournamentManager.Tests.RoundRobin; + +/// +/// IEquatable is implemented implicitly. +/// +internal record struct CustomTestStruct +{ + public int A { get; set; } + public int B { get; set; } + public int C { get; set; } + + public override readonly string ToString() + { + return $"{A}-{B}-{C}"; + } +} diff --git a/TournamentManager/TournamentManager.Tests/RoundRobin/IdealRoundRobinTests.cs b/TournamentManager/TournamentManager.Tests/RoundRobin/IdealRoundRobinTests.cs new file mode 100644 index 00000000..ebaf0693 --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/RoundRobin/IdealRoundRobinTests.cs @@ -0,0 +1,143 @@ +using NUnit.Framework; +using TournamentManager.RoundRobin; + +namespace TournamentManager.Tests.RoundRobin; + +[TestFixture] +internal class IdealRoundRobinTests +{ + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + [TestCase(9)] + [TestCase(10)] + [TestCase(11)] + [TestCase(12)] + [TestCase(13)] + [TestCase(14)] + public void EachCombinationIsUnique(int numOfParticipants) + { + var participants = GetParticipants(numOfParticipants); + + var roundRobin = new IdealRoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + var uniqueCombinations = new HashSet(); + foreach (var match in matches) + { + var combination = $"{match.Home}-{match.Guest}"; + uniqueCombinations.Add(combination); + } + + Assert.That(matches.Count, Is.EqualTo(uniqueCombinations.Count)); + Assert.That(matches.First().Turn, Is.EqualTo(1)); // first turn is 1 + } + + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + [TestCase(9)] + [TestCase(10)] + [TestCase(11)] + [TestCase(12)] + [TestCase(13)] + [TestCase(14)] + public void CreateIdealRoundRobinTournaments(int numOfParticipants) + { + var participants = GetParticipants(numOfParticipants); + + var roundRobin = new IdealRoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + Assert.That(matches.Count, Is.EqualTo((numOfParticipants - 1) * numOfParticipants / 2)); + } + + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + [TestCase(9)] + [TestCase(10)] + [TestCase(11)] + [TestCase(12)] + [TestCase(13)] + [TestCase(14)] + public void MaxConsecutiveHomeGuest(int numOfParticipants) + { + var participants = GetParticipants(numOfParticipants); + + var roundRobin = new IdealRoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + var participantsWithConsecutiveHomeGuestMatches = new List(); + + // Only even number of participants may have some 2 consecutive home/guest matches + foreach (var participant in participants) + { + var maxConsecutiveHomeGuest = MatchesAnalyzer.GetMaxConsecutiveHomeGuestCount(participant, matches); + if (numOfParticipants % 2 == 0 && maxConsecutiveHomeGuest.HomeCount == 2 || maxConsecutiveHomeGuest.GuestCount == 2) + { + participantsWithConsecutiveHomeGuestMatches.Add(participant); + } + + Assert.That(maxConsecutiveHomeGuest.HomeCount, Is.LessThanOrEqualTo(numOfParticipants % 2 == 0 ? 2 : 1)); + Assert.That(maxConsecutiveHomeGuest.GuestCount, Is.LessThanOrEqualTo(numOfParticipants % 2 == 0 ? 2 : 1)); + } + + // Only even number of participants may have consecutive home/guest matches + Assert.That(participantsWithConsecutiveHomeGuestMatches.Count, Is.EqualTo(numOfParticipants % 2 == 0 ? numOfParticipants - 2 : 0)); + } + + [TestCase(4)] + [TestCase(15)] + public void NumberOfParticipantsOutOfRange(int numOfParticipants) + { + var participants = GetParticipants(numOfParticipants); + + Assert.Throws(() => + new IdealRoundRobinSystem(participants).GenerateMatches()); + } + + [Test] + public void CreateRoundRobinTournamentWithCustomStruct() + { + var participants = new List { + new() { A = 1, B = 1, C = 1 }, new() { A = 2, B = 2, C = 2 }, + new() { A = 3, B = 3, C = 3 }, new() { A = 4, B = 4, C = 4 }, + new() { A = 5, B = 5, C = 5 } + }; + + var roundRobin = new IdealRoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + Assert.That(matches.Count, Is.EqualTo((participants.Count - 1) * participants.Count / 2)); + } + + [Test] + public void CreateIdealRoundRobinTournamentWithCustomStruct() + { + var participants = new List { + new() { A = 1, B = 1, C = 1 }, new() { A = 2, B = 2, C = 2 }, + new() { A = 3, B = 3, C = 3 }, new() { A = 4, B = 4, C = 4 }, + new() { A = 5, B = 5, C = 5 } + }; + + var roundRobin = new IdealRoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + Assert.That(matches.Count, Is.EqualTo((participants.Count - 1) * participants.Count / 2)); + } + + private static List GetParticipants(int numOfParticipants) + { + var participants = new List(numOfParticipants); + for (var i = 1; i <= numOfParticipants; i++) + { + participants.Add(i); + } + + return participants; + } +} diff --git a/TournamentManager/TournamentManager.Tests/RoundRobin/RoundRobinTests.cs b/TournamentManager/TournamentManager.Tests/RoundRobin/RoundRobinTests.cs new file mode 100644 index 00000000..db764d7c --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/RoundRobin/RoundRobinTests.cs @@ -0,0 +1,139 @@ +using NUnit.Framework; +using TournamentManager.RoundRobin; + +namespace TournamentManager.Tests.RoundRobin; + +[TestFixture] +internal class RoundRobinTests +{ + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + [TestCase(9)] + [TestCase(10)] + [TestCase(11)] + [TestCase(12)] + [TestCase(13)] + [TestCase(14)] + [TestCase(15)] + [TestCase(16)] + public void EachCombinationIsUnique(int numOfParticipants) + { + var participants = GetParticipants(numOfParticipants); + + var roundRobin = new RoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + var uniqueCombinations = new HashSet(); + foreach (var match in matches) + { + var combination = $"{match.Home}-{match.Guest}"; + uniqueCombinations.Add(combination); + } + + Assert.That(matches.Count, Is.EqualTo(uniqueCombinations.Count)); + Assert.That(matches.First().Turn, Is.EqualTo(1)); // first turn is 1 + } + + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + [TestCase(9)] + [TestCase(10)] + [TestCase(11)] + [TestCase(12)] + [TestCase(13)] + [TestCase(14)] + [TestCase(15)] + [TestCase(16)] + public void CreateRoundRobinTournaments(int numOfParticipants) + { + var participants = GetParticipants(numOfParticipants); + + var roundRobin = new RoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + Assert.That(matches.Count, Is.EqualTo((numOfParticipants - 1) * numOfParticipants / 2)); + } + + [TestCase(4)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + [TestCase(9)] + [TestCase(10)] + [TestCase(11)] + [TestCase(12)] + [TestCase(13)] + [TestCase(16)] + public void MaxConsecutiveHomeGuest(int numOfParticipants) + { + var participants = GetParticipants(numOfParticipants); + + var roundRobin = new RoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + var participantsWithConsecutiveHomeGuestMatches = new List(); + + foreach (var participant in participants) + { + var maxConsecutiveHomeGuest = MatchesAnalyzer.GetMaxConsecutiveHomeGuestCount(participant, matches); + if (maxConsecutiveHomeGuest.HomeCount == 2 || maxConsecutiveHomeGuest.GuestCount == 2) + { + participantsWithConsecutiveHomeGuestMatches.Add(participant); + } + + Assert.That(maxConsecutiveHomeGuest.HomeCount, Is.LessThanOrEqualTo(2)); + Assert.That(maxConsecutiveHomeGuest.GuestCount, Is.LessThanOrEqualTo(2)); + } + + Assert.That(participantsWithConsecutiveHomeGuestMatches.Count, Is.EqualTo(numOfParticipants % 2 == 0 ? numOfParticipants - 2 : numOfParticipants - 1)); + } + + [Test] + public void CreateRoundRobinTournamentWithCustomStruct() + { + var participants = new List { + new() { A = 1, B = 1, C = 1 }, new() { A = 2, B = 2, C = 2 }, + new() { A = 3, B = 3, C = 3 }, new() { A = 4, B = 4, C = 4 } + }; + + var roundRobin = new RoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + Assert.That(matches.Count, Is.EqualTo((participants.Count - 1) * participants.Count / 2)); + } + + [Test] + public void CreateIdealRoundRobinTournamentWithCustomStruct() + { + var participants = new List { + new() { A = 1, B = 1, C = 1 }, new() { A = 2, B = 2, C = 2 }, + new() { A = 3, B = 3, C = 3 }, new() { A = 4, B = 4, C = 4 }, + new() { A = 5, B = 5, C = 5 } + }; + + var roundRobin = new RoundRobinSystem(participants); + var matches = roundRobin.GenerateMatches(); + + Assert.That(matches.Count, Is.EqualTo((participants.Count - 1) * participants.Count / 2)); + } + + private static List GetParticipants(int numOfParticipants) + { + var participants = new List(numOfParticipants); + for (var i = 1; i <= numOfParticipants; i++) + { + participants.Add(i); + } + + return participants; + } +} + diff --git a/TournamentManager/TournamentManager/Data/AvailableMatchDateRepository.cs b/TournamentManager/TournamentManager/Data/AvailableMatchDateRepository.cs index e2697e69..29694dc1 100644 --- a/TournamentManager/TournamentManager/Data/AvailableMatchDateRepository.cs +++ b/TournamentManager/TournamentManager/Data/AvailableMatchDateRepository.cs @@ -2,6 +2,7 @@ using SD.LLBLGen.Pro.ORMSupportClasses; using TournamentManager.DAL.EntityClasses; using TournamentManager.DAL.HelperClasses; +using TournamentManager.Plan; namespace TournamentManager.Data; @@ -23,7 +24,7 @@ public AvailableMatchDateRepository(MultiTenancy.IDbContext dbContext) /// /// /// Returns the of type for a tournament. - public async Task> GetAvailableMatchDatesAsync (long tournamentId, CancellationToken cancellationToken) + public virtual async Task> GetAvailableMatchDatesAsync (long tournamentId, CancellationToken cancellationToken) { var available = new EntityCollection(); using var da = _dbContext.GetNewAdapter(); @@ -35,6 +36,46 @@ public async Task> GetAvailableMatchD await da.FetchEntityCollectionAsync(qp, cancellationToken); da.CloseConnection(); + _logger.LogDebug("Fetched {count} available match dates for tournament {tournamentId}.", available.Count, tournamentId); + return available; } + + /// + /// Removes entries in AvailableMatchDates database table. + /// + /// The tournament ID. + /// Which entries to delete for the tournament. + /// + /// Returns the number of deleted records. + public virtual async Task ClearAsync(long tournamentId, MatchDateClearOption clear, CancellationToken cancellationToken) + { + var deleted = 0; + + // tournament is always in the filter + var filterAvailable = new RelationPredicateBucket(); + filterAvailable.PredicateExpression.Add(AvailableMatchDateFields.TournamentId == tournamentId); + + if ((clear & MatchDateClearOption.All) == MatchDateClearOption.All) + { + deleted = await _dbContext.AppDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), + null, cancellationToken); + } + else if ((clear & MatchDateClearOption.OnlyAutoGenerated) == MatchDateClearOption.OnlyAutoGenerated) + { + filterAvailable.PredicateExpression.AddWithAnd(AvailableMatchDateFields.IsGenerated == true); + deleted = await _dbContext.AppDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), + filterAvailable, cancellationToken); + } + else if ((clear & MatchDateClearOption.OnlyManual) == MatchDateClearOption.OnlyManual) + { + filterAvailable.PredicateExpression.AddWithAnd(AvailableMatchDateFields.IsGenerated == false); + deleted = await _dbContext.AppDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), + filterAvailable, cancellationToken); + } + + _logger.LogDebug("Deleted {deleted} available match dates for tournament {tournamentId}.", deleted, tournamentId); + + return deleted; + } } diff --git a/TournamentManager/TournamentManager/Data/ExcludedMatchDateRepository.cs b/TournamentManager/TournamentManager/Data/ExcludedMatchDateRepository.cs index 8aa8170d..e6575663 100644 --- a/TournamentManager/TournamentManager/Data/ExcludedMatchDateRepository.cs +++ b/TournamentManager/TournamentManager/Data/ExcludedMatchDateRepository.cs @@ -27,7 +27,7 @@ public ExcludedMatchDateRepository(MultiTenancy.IDbContext dbContext) /// /// /// Returns the of type for a tournament. - public async Task> GetExcludedMatchDatesAsync (long tournamentId, CancellationToken cancellationToken) + public virtual async Task> GetExcludedMatchDatesAsync (long tournamentId, CancellationToken cancellationToken) { var excluded = new EntityCollection(); using var da = _dbContext.GetNewAdapter(); @@ -49,7 +49,8 @@ public async Task> GetExcludedMatchDate /// but only for the team or round. /// /// - /// Same behavior as with . + /// Same conditions as with + /// which uses a cached list of s. /// /// The where RoundId and TeamId are taken. /// The TournamentId to filter the result. @@ -58,7 +59,7 @@ public async Task> GetExcludedMatchDate public virtual async Task GetExcludedMatchDateAsync(MatchEntity match, long tournamentId, CancellationToken cancellationToken) { - if (!(match.PlannedStart.HasValue && match.PlannedEnd.HasValue)) return null; + if (match is not { PlannedStart: not null, PlannedEnd: not null }) return null; using var da = _dbContext.GetNewAdapter(); var tournamentFilter = new PredicateExpression( diff --git a/TournamentManager/TournamentManager/Data/GenericRepository.cs b/TournamentManager/TournamentManager/Data/GenericRepository.cs index c83521e8..5bdfbdf3 100644 --- a/TournamentManager/TournamentManager/Data/GenericRepository.cs +++ b/TournamentManager/TournamentManager/Data/GenericRepository.cs @@ -77,7 +77,7 @@ public virtual async Task DeleteEntitiesAsync(T entitiesToDelete, Cancellatio var count = await da.DeleteEntityCollectionAsync(entitiesToDelete, cancellationToken); } - public virtual async Task DeleteEntitiesDirectlyAsync(Type entityType, IRelationPredicateBucket filterBucket, CancellationToken cancellationToken) + public virtual async Task DeleteEntitiesDirectlyAsync(Type entityType, IRelationPredicateBucket? filterBucket, CancellationToken cancellationToken) { using var da = _dbContext.GetNewAdapter(); return await da.DeleteEntitiesDirectlyAsync(entityType, filterBucket, cancellationToken); @@ -97,4 +97,4 @@ public virtual async Task DeleteEntitiesUsingConstraintAsync(IPredicateE var bucket = new RelationPredicateBucket(uniqueConstraintFilter); return await da.DeleteEntitiesDirectlyAsync(typeof(T), bucket, cancellationToken); } -} \ No newline at end of file +} diff --git a/TournamentManager/TournamentManager/Data/MatchRepository.cs b/TournamentManager/TournamentManager/Data/MatchRepository.cs index acacf3b6..4276aebf 100644 --- a/TournamentManager/TournamentManager/Data/MatchRepository.cs +++ b/TournamentManager/TournamentManager/Data/MatchRepository.cs @@ -70,7 +70,7 @@ public virtual async Task> GetPlannedMatchesAsync(IPredica { using var da = _dbContext.GetNewAdapter(); - if (!(await GetPlannedMatchesAsync(new PredicateExpression(PlannedMatchFields.TournamentId == tournamentId & PlannedMatchFields.Id == id), cancellationToken)).Any()) + if ((await GetPlannedMatchesAsync(new PredicateExpression(PlannedMatchFields.TournamentId == tournamentId & PlannedMatchFields.Id == id), cancellationToken)).Count == 0) return null; return (await da.FetchQueryAsync( @@ -105,38 +105,48 @@ public virtual async Task> GetMatchCalendarAsync(long tourname new QueryFactory().Calendar.Where(filter), cancellationToken)); } - public virtual EntityCollection GetMatches(long tournamentId) + public virtual async Task> GetMatches(long tournamentId, CancellationToken cancellationToken) { var rounds = new TournamentRepository(_dbContext).GetTournamentRounds(tournamentId); var roundId = new List(rounds.Count); roundId.AddRange(rounds.Select(round => round.Id)); - IRelationPredicateBucket bucket = new RelationPredicateBucket(); IPredicateExpression roundFilter = new PredicateExpression(new FieldCompareRangePredicate(MatchFields.RoundId, null, false, roundId.ToArray())); - bucket.PredicateExpression.AddWithAnd(roundFilter); - + var matches = new EntityCollection(); using var da = _dbContext.GetNewAdapter(); - da.FetchEntityCollection(matches, bucket); + + var qp = new QueryParameters + { + CollectionToFetch = matches, + FilterToUseAsPredicateExpression = { roundFilter } + }; + + await da.FetchEntityCollectionAsync(qp, cancellationToken); da.CloseConnection(); return matches; } - public virtual EntityCollection GetMatches(RoundEntity round) + public virtual async Task> GetMatches(RoundEntity round, CancellationToken cancellationToken) { - IRelationPredicateBucket bucket = new RelationPredicateBucket(); IPredicateExpression roundFilter = new PredicateExpression(new FieldCompareRangePredicate(MatchFields.RoundId, null, false, new[] {round.Id})); - bucket.PredicateExpression.AddWithAnd(roundFilter); var matches = new EntityCollection(); using var da = _dbContext.GetNewAdapter(); - da.FetchEntityCollection(matches, bucket); + + var qp = new QueryParameters + { + CollectionToFetch = matches, + FilterToUseAsPredicateExpression = { roundFilter } + }; + + await da.FetchEntityCollectionAsync(qp, cancellationToken); da.CloseConnection(); return matches; @@ -149,7 +159,7 @@ public virtual EntityCollection GetMatches(RoundEntity round) if (!match.LegSequenceNo.HasValue) return null; - if (match.Round != null && match.Round.RoundLegs != null) + if (match.Round is { RoundLegs: not null }) { return match.Round.RoundLegs.First(l => l.SequenceNo == match.LegSequenceNo); } @@ -268,13 +278,13 @@ public virtual async Task GetMatchCountAsync(PredicateExpression filter, Ca /// of the tournament rounds. /// /// Returns true if matches were found, else false. - public virtual bool AnyCompleteMatchesExist(long tournamentId) + public virtual async Task AnyCompleteMatchesExistAsync(long tournamentId, CancellationToken cancellationToken) { using var da = _dbContext.GetNewAdapter(); var metaData = new LinqMetaData(da); - if ((from matchEntity in metaData.Match + if ((await (from matchEntity in metaData.Match where matchEntity.Round.TournamentId == tournamentId && matchEntity.IsComplete - select matchEntity.Id).AsEnumerable().Any()) + select matchEntity.Id).Take(1).ToListAsync(cancellationToken)).Count != 0) { da.CloseConnection(); return true; @@ -290,14 +300,15 @@ public virtual bool AnyCompleteMatchesExist(long tournamentId) /// a tournament round. /// /// RoundEntity (only Id will be used) + /// /// Returns true if matches were found, else false. - public virtual bool AnyCompleteMatchesExist(RoundEntity round) + public virtual async Task AnyCompleteMatchesExistAsync(RoundEntity round, CancellationToken cancellationToken) { using var da = _dbContext.GetNewAdapter(); var metaData = new LinqMetaData(da); - if ((from matchEntity in metaData.Match + if ((await (from matchEntity in metaData.Match where matchEntity.Round.Id == round.Id && matchEntity.IsComplete - select matchEntity.Id).AsEnumerable().Any()) + select matchEntity.Id).Take(1).ToListAsync(cancellationToken)).Count != 0) { da.CloseConnection(); return true; @@ -312,44 +323,44 @@ public virtual bool AnyCompleteMatchesExist(RoundEntity round) /// Find out whether all matches of a tournament with a given Id are completed /// /// Returns true if all matches are completed, else false. - public virtual bool AllMatchesCompleted(TournamentEntity tournament) + public virtual async Task AllMatchesCompletedAsync(TournamentEntity tournament, CancellationToken cancellationToken) { using var da = _dbContext.GetNewAdapter(); var metaData = new LinqMetaData(da); - if ((from matchEntity in metaData.Match + if ((await (from matchEntity in metaData.Match where matchEntity.Round.TournamentId == tournament.Id && !matchEntity.IsComplete - select matchEntity.Id).AsEnumerable().Any()) + select matchEntity.Id).Take(1).ToListAsync(cancellationToken)).Count == 0) { da.CloseConnection(); - return false; + return true; } da.CloseConnection(); - return true; + return false; } /// /// Find out whether all matches of a round with a gived Id are completed /// /// Returns true if all matches are completed, else false. - public virtual bool AllMatchesCompleted(RoundEntity round) + public virtual async Task AllMatchesCompletedAsync(RoundEntity round, CancellationToken cancellationToken) { using var da = _dbContext.GetNewAdapter(); var metaData = new LinqMetaData(da); - if ((from matchEntity in metaData.Match + if (( await (from matchEntity in metaData.Match where matchEntity.Round.Id == round.Id && !matchEntity.IsComplete - select matchEntity.Id).AsEnumerable().Any()) + select matchEntity.Id).Take(1).ToListAsync(cancellationToken)).Count == 0) { da.CloseConnection(); - return false; + return true; } da.CloseConnection(); - return true; + return false; } /// diff --git a/TournamentManager/TournamentManager/Data/RoundRepository.cs b/TournamentManager/TournamentManager/Data/RoundRepository.cs index 1bfa44d0..2df95ee3 100644 --- a/TournamentManager/TournamentManager/Data/RoundRepository.cs +++ b/TournamentManager/TournamentManager/Data/RoundRepository.cs @@ -52,22 +52,10 @@ public virtual async Task> GetRoundLegPeriodAsync(IPredi public virtual async Task GetRoundWithLegsAsync(long roundId, CancellationToken cancellationToken) { using var da = _dbContext.GetNewAdapter(); - var result = (await da.FetchQueryAsync( - new QueryFactory().Round.Where(RoundFields.Id == roundId) - .WithPath(RoundEntity.PrefetchPathRoundLegs), cancellationToken)).Cast().ToList(); - return result.FirstOrDefault(); - } - - public virtual RoundEntity GetRoundWithLegs(long roundId) - { - var round = new RoundEntity(roundId); - IPrefetchPath2 prefetchPathRoundLegs = new PrefetchPath2(EntityType.RoundEntity) - { - RoundEntity.PrefetchPathRoundLegs - }; - - using var da = _dbContext.GetNewAdapter(); - da.FetchEntity(round, prefetchPathRoundLegs); + var metaData = new LinqMetaData(da); + var round = await metaData.Round.Where(r => r.Id == roundId) + .WithPath(new PathEdge(RoundEntity.PrefetchPathRoundLegs)) + .FirstOrDefaultAsync(cancellationToken); da.CloseConnection(); return round; } @@ -99,4 +87,4 @@ public virtual async Task> GetRoundsWithTypeAsync(PredicateExp return ((EntityCollection) await da.FetchQueryAsync( new QueryFactory().Round.WithPath(RoundEntity.PrefetchPathRoundType).Where(filter), cancellationToken)).ToList(); } -} \ No newline at end of file +} diff --git a/TournamentManager/TournamentManager/Data/TournamentRepository.cs b/TournamentManager/TournamentManager/Data/TournamentRepository.cs index 6d6ab1ea..6afde57b 100644 --- a/TournamentManager/TournamentManager/Data/TournamentRepository.cs +++ b/TournamentManager/TournamentManager/Data/TournamentRepository.cs @@ -64,7 +64,7 @@ public virtual EntityCollection GetTournamentRounds(long tournament return result; } - public virtual async Task GetTournamentEntityForMatchPlannerAsync(long tournamentId, CancellationToken cancellationToken) + public virtual async Task GetTournamentEntityForMatchSchedulerAsync(long tournamentId, CancellationToken cancellationToken) { var bucket = new RelationPredicateBucket(TournamentFields.Id == tournamentId); bucket.Relations.Add(TournamentEntity.Relations.RoundEntityUsingTournamentId); diff --git a/TournamentManager/TournamentManager/Data/VenueRepository.cs b/TournamentManager/TournamentManager/Data/VenueRepository.cs index 929059a9..fe7b831b 100644 --- a/TournamentManager/TournamentManager/Data/VenueRepository.cs +++ b/TournamentManager/TournamentManager/Data/VenueRepository.cs @@ -53,7 +53,7 @@ public virtual async Task> GetOccupyingMatchesAsync(long v public virtual async Task IsValidVenueIdAsync(long? venueId, CancellationToken cancellationToken) { var result = (await GetVenuesAsync(new PredicateExpression(VenueFields.Id.Equal(venueId)), cancellationToken)).Count == 1; - _logger.LogDebug("Valid venue: {trueFalse}", result); + _logger.LogDebug("Valid venue: {validVenue}", result); return result; } @@ -91,4 +91,4 @@ public virtual async Task> GetVenueTeamRowsAsync(IPredicateEx return await da.FetchQueryAsync( new QueryFactory().VenueTeam.Where(filter), cancellationToken); } -} \ No newline at end of file +} diff --git a/TournamentManager/TournamentManager/ExtensionMethods/MatchEntityListExtensions.cs b/TournamentManager/TournamentManager/ExtensionMethods/MatchEntityListExtensions.cs new file mode 100644 index 00000000..c2e3991b --- /dev/null +++ b/TournamentManager/TournamentManager/ExtensionMethods/MatchEntityListExtensions.cs @@ -0,0 +1,62 @@ +using TournamentManager.DAL.EntityClasses; +using TournamentManager.DAL.HelperClasses; + +namespace TournamentManager.ExtensionMethods; + +/// +/// Extension methods for s +/// +public static class MatchEntityListExtensions +{ + /// + /// Returns the previous matches relative to the for the given . + /// + /// + /// Starts searching for matches BEFORE the index. + /// The or to search. + /// Set to true to include with missing or or + /// The of indexes for previous matches relative to the for the given + /// + public static IEnumerable GetPreviousMatches(this IList matches, int currentIndex, long[] teamIds, bool includeUndefinedStartOrVenue) + { + if (currentIndex < 0 || currentIndex >= matches.Count) + throw new ArgumentOutOfRangeException(nameof(currentIndex), currentIndex, @$"Index must be less than {matches.Count - 1}."); + + var index = currentIndex; + while (index > 0) + { + index--; + var current = matches[index]; + if ((teamIds.Contains(current.HomeTeamId) || teamIds.Contains(current.GuestTeamId)) && + (includeUndefinedStartOrVenue || current is + { PlannedStart: not null, PlannedEnd: not null, VenueId: not null })) + yield return index; + } + } + + /// + /// Returns the next matches relative to the for the given . + /// + /// + /// Starts searching for matches AFTER the index. + /// The or to search. + /// Set to true to include with missing or or + /// The of indexes for next matches relative to the for the given + /// + public static IEnumerable GetNextMatches(this IList matches, int currentIndex, long[] teamIds, bool includeUndefinedStartOrVenue) + { + if (currentIndex < 0 || currentIndex >= matches.Count) + throw new ArgumentOutOfRangeException(nameof(currentIndex), currentIndex, @$"Index must be less than {matches.Count - 1}."); + + var index = currentIndex; + while (index < matches.Count - 1) + { + index++; + var current = matches[index]; + if ((teamIds.Contains(current.HomeTeamId) || teamIds.Contains(current.GuestTeamId)) && + (includeUndefinedStartOrVenue || current is + { PlannedStart: not null, PlannedEnd: not null, VenueId: not null })) + yield return index; + } + } +} diff --git a/TournamentManager/TournamentManager/MultiTenancy/AppDb.cs b/TournamentManager/TournamentManager/MultiTenancy/AppDb.cs index d76a2965..5c75947a 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/AppDb.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/AppDb.cs @@ -39,4 +39,4 @@ public AppDb(IDbContext dbContext) public virtual VenueRepository VenueRepository => new(DbContext); public virtual ExcludedMatchDateRepository ExcludedMatchDateRepository => new(DbContext); public virtual AvailableMatchDateRepository AvailableMatchDateRepository => new(DbContext); -} \ No newline at end of file +} diff --git a/TournamentManager/TournamentManager/MultiTenancy/DbContext.cs b/TournamentManager/TournamentManager/MultiTenancy/DbContext.cs index d1672e97..40ab81f4 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/DbContext.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/DbContext.cs @@ -9,10 +9,11 @@ namespace TournamentManager.MultiTenancy; public class DbContext : IDbContext { private readonly object _locker = new(); + private readonly IAppDb _appDb; public DbContext() { - AppDb = new AppDb(this); + _appDb = new AppDb(this); } /// @@ -94,5 +95,5 @@ public virtual IDataAccessAdapter GetNewAdapter() /// Gives access to the repositories. /// [YAXLib.Attributes.YAXDontSerialize] - public virtual AppDb AppDb { get; } + public virtual IAppDb AppDb => _appDb; } diff --git a/TournamentManager/TournamentManager/MultiTenancy/IAppDb.cs b/TournamentManager/TournamentManager/MultiTenancy/IAppDb.cs index ecb0cbc9..3dd5a856 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/IAppDb.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/IAppDb.cs @@ -1,12 +1,30 @@ -namespace TournamentManager.MultiTenancy; +using TournamentManager.Data; + +namespace TournamentManager.MultiTenancy; -/// -/// Interface for accessing the repositories. -/// public interface IAppDb { /// - /// The instance to be used to access the repositories. + /// Provides database-specific settings. /// - MultiTenancy.IDbContext DbContext { get; } -} \ No newline at end of file + IDbContext DbContext { get; } + + GenericRepository GenericRepository { get; } + ManagerOfTeamRepository ManagerOfTeamRepository { get; } + MatchRepository MatchRepository { get; } + PlayerInTeamRepository PlayerInTeamRepository { get; } + RoundRepository RoundRepository { get; } + TeamInRoundRepository TeamInRoundRepository { get; } + TeamRepository TeamRepository { get; } + TournamentRepository TournamentRepository { get; } + RankingRepository RankingRepository { get; } + RoleRepository RoleRepository { get; } + UserRepository UserRepository { get; } + UserRoleRepository UserRoleRepository { get; } + UserClaimRepository UserClaimRepository { get; } + UserLoginRepository UserLoginRepository { get; } + UserTokenRepository UserTokenRepository { get; } + VenueRepository VenueRepository { get; } + ExcludedMatchDateRepository ExcludedMatchDateRepository { get; } + AvailableMatchDateRepository AvailableMatchDateRepository { get; } +} diff --git a/TournamentManager/TournamentManager/MultiTenancy/IDbContext.cs b/TournamentManager/TournamentManager/MultiTenancy/IDbContext.cs index ddf00321..22b4a2c3 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/IDbContext.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/IDbContext.cs @@ -44,11 +44,11 @@ public interface IDbContext /// /// Gives access to the repositories. /// - AppDb AppDb { get; } + IAppDb AppDb { get; } /// /// Gets a new instance of an which will be used to access repositories. /// /// Returns a new instance of an which will be used to access repositories. IDataAccessAdapter GetNewAdapter(); -} \ No newline at end of file +} diff --git a/TournamentManager/TournamentManager/MultiTenancy/ITournamentContext.cs b/TournamentManager/TournamentManager/MultiTenancy/ITournamentContext.cs index edb83358..8035a238 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/ITournamentContext.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/ITournamentContext.cs @@ -59,4 +59,9 @@ public interface ITournamentContext /// Rules for team master data /// TeamRules TeamRuleSet { get; set; } -} \ No newline at end of file + + /// + /// Rules for referee master data + /// + RefereeRules RefereeRuleSet { get; set; } +} diff --git a/TournamentManager/TournamentManager/MultiTenancy/TenantContext.cs b/TournamentManager/TournamentManager/MultiTenancy/TenantContext.cs index 66538bc2..8ba79927 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/TenantContext.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/TenantContext.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace TournamentManager.MultiTenancy; +namespace TournamentManager.MultiTenancy; [YAXLib.Attributes.YAXSerializeAs(nameof(TenantContext))] public class TenantContext : ITenantContext diff --git a/TournamentManager/TournamentManager/MultiTenancy/TournamentContext.cs b/TournamentManager/TournamentManager/MultiTenancy/TournamentContext.cs index 38641c5e..3bc5ecc0 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/TournamentContext.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/TournamentContext.cs @@ -73,6 +73,12 @@ public class TournamentContext : ITournamentContext /// [YAXLib.Attributes.YAXComment("The rules which apply for creating and editing team data")] public TeamRules TeamRuleSet { get; set; } = new(); + + /// + /// Rules for referee master data. + /// + [YAXLib.Attributes.YAXComment("Rules for referee master data")] + public RefereeRules RefereeRuleSet { get; set; } = new(); } public class FixtureRuleSet @@ -159,6 +165,18 @@ public class TeamRules public HomeVenue HomeVenue { get; set; } = new(); } +/// +/// Rules for referee master data. +/// +public class RefereeRules +{ + /// + /// Rules for teams' home match time + /// + [YAXLib.Attributes.YAXComment("Rule for organizing referees")] + public Plan.RefereeType RefereeType { get; set; } = Plan.RefereeType.None; +} + /// /// Rules for the of a team. /// diff --git a/TournamentManager/TournamentManager/Plan/AvailableMatchDates.cs b/TournamentManager/TournamentManager/Plan/AvailableMatchDates.cs index 4be8144a..af5bfc2a 100644 --- a/TournamentManager/TournamentManager/Plan/AvailableMatchDates.cs +++ b/TournamentManager/TournamentManager/Plan/AvailableMatchDates.cs @@ -1,28 +1,30 @@ using TournamentManager.DAL.EntityClasses; using TournamentManager.DAL.HelperClasses; -using SD.LLBLGen.Pro.ORMSupportClasses; using Microsoft.Extensions.Logging; using TournamentManager.Data; using TournamentManager.MultiTenancy; namespace TournamentManager.Plan; -public class AvailableMatchDates +/// +/// This class manages available match dates. +/// +internal class AvailableMatchDates { private readonly ITenantContext _tenantContext; - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly Axuno.Tools.DateAndTime.TimeZoneConverter _timeZoneConverter; private readonly ILogger _logger; // available match dates from database - private readonly EntityCollection _availableMatchDateEntities = new(); + private readonly EntityCollection _availableDatesFromDb = new(); // programmatically generated available match dates - private readonly EntityCollection _generatedAvailableMatchDateEntities = new(); + private readonly EntityCollection _generatedAvailableDates = new(); // excluded dates - private readonly EntityCollection _excludedMatchDateEntities = new(); + private readonly EntityCollection _excludedMatchDates = new(); internal AvailableMatchDates(ITenantContext tenantContext, Axuno.Tools.DateAndTime.TimeZoneConverter timeZoneConverter, ILogger logger) @@ -40,58 +42,41 @@ internal AvailableMatchDates(ITenantContext tenantContext, private async Task Initialize(CancellationToken cancellationToken) { _logger.LogDebug($"Initializing {nameof(AvailableMatchDates)}"); - _excludedMatchDateEntities.Clear(); - _excludedMatchDateEntities.AddRange( + _excludedMatchDates.Clear(); + _excludedMatchDates.AddRange( await _appDb.ExcludedMatchDateRepository.GetExcludedMatchDatesAsync( _tenantContext.TournamentContext.MatchPlanTournamentId, cancellationToken)); - _logger.LogDebug("{count} excluded match dates loaded from storage", _excludedMatchDateEntities.Count); + _logger.LogDebug("{count} excluded match dates loaded from storage", _excludedMatchDates.Count); - _availableMatchDateEntities.Clear(); - _availableMatchDateEntities.AddRange( + _availableDatesFromDb.Clear(); + _availableDatesFromDb.AddRange( await _appDb.AvailableMatchDateRepository.GetAvailableMatchDatesAsync( _tenantContext.TournamentContext.MatchPlanTournamentId, cancellationToken)); - _logger.LogDebug("{count} available match dates loaded from storage", _availableMatchDateEntities.Count); + _logger.LogDebug("{count} available match dates loaded from storage", _availableDatesFromDb.Count); - _generatedAvailableMatchDateEntities.Clear(); + _generatedAvailableDates.Clear(); } /// - /// Removes entries in AvailableMatchDates database table. + /// Removes entries in database table and internal cache. /// /// Which entries to delete for the tournament. /// /// Returns the number of deleted records. - internal async Task ClearAsync(ClearMatchDates clear, CancellationToken cancellationToken) + public async Task ClearAsync(MatchDateClearOption clear, CancellationToken cancellationToken) { - var deleted = 0; + var deleted = + await _appDb.AvailableMatchDateRepository.ClearAsync(_tenantContext.TournamentContext.MatchPlanTournamentId, + clear, cancellationToken); - // tournament is always in the filter - var filterAvailable = new RelationPredicateBucket(); - filterAvailable.PredicateExpression.Add(AvailableMatchDateFields.TournamentId == - _tenantContext.TournamentContext.MatchPlanTournamentId); + if((clear & MatchDateClearOption.OnlyAutoGenerated) == MatchDateClearOption.OnlyAutoGenerated) + _generatedAvailableDates.Clear(); - if (clear == ClearMatchDates.All) - { - deleted = await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), - null!, cancellationToken); - _generatedAvailableMatchDateEntities.Clear(); - } - else if (clear == ClearMatchDates.OnlyAutoGenerated) - { - filterAvailable.PredicateExpression.AddWithAnd(AvailableMatchDateFields.IsGenerated == true); - deleted = await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), - filterAvailable, cancellationToken); - _generatedAvailableMatchDateEntities.Clear(); - } - else if (clear == ClearMatchDates.OnlyManual) - { - filterAvailable.PredicateExpression.AddWithAnd(AvailableMatchDateFields.IsGenerated == false); - deleted = await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), - filterAvailable, cancellationToken); - } + if ((clear & MatchDateClearOption.OnlyManual) == MatchDateClearOption.OnlyManual) + _availableDatesFromDb.Clear(); return deleted; } @@ -100,25 +85,28 @@ internal async Task ClearAsync(ClearMatchDates clear, CancellationToken can /// Checks the for , /// and for not . /// - private bool IsVenueAndDateDefined(TeamEntity team) + private static bool IsVenueAndDateDefined(TeamEntity team) { return team is { MatchDayOfWeek: not null, MatchTime: not null, VenueId: not null }; } /// /// Verifies, that the given is within the date - /// bounderies, and it is not excluded, and the venue is not occupied by another match. + /// boundaries, and it is not excluded, and the venue is not occupied by another match. /// - private async Task IsDateUsable(DateTime matchDateTimeUtc, RoundLegEntity roundLeg, TeamEntity team, CancellationToken cancellationToken) + private bool IsDateUsable(DateTime matchDateTimeUtc, RoundLegEntity roundLeg, IEnumerable tournamentMatches, TeamEntity team) { var plannedDuration = _tenantContext.TournamentContext.FixtureRuleSet.PlannedDurationOfMatch; - // Todo: This code creates heavy load on the database - return IsDateWithinRoundLegDateTime(roundLeg, matchDateTimeUtc) + var isAvailable = IsDateWithinRoundLegDateTime(roundLeg, matchDateTimeUtc) && !IsExcludedDate(matchDateTimeUtc, roundLeg.RoundId, team.Id) - && !await IsVenueOccupiedByMatchAsync( + && !IsVenueOccupiedByMatch( new DateTimePeriod(matchDateTimeUtc, matchDateTimeUtc.Add(plannedDuration)), - team.VenueId!.Value, cancellationToken); + team.VenueId!.Value, tournamentMatches); + + _logger.LogDebug("Venue '{venueId}' is available for '{matchDateTimeUtc}': {isAvailable}", team.VenueId, matchDateTimeUtc, isAvailable); + + return isAvailable; } @@ -128,9 +116,10 @@ private async Task IsDateUsable(DateTime matchDateTimeUtc, RoundLegEntity /// are not . /// /// + /// /// /// - internal async Task GenerateNewAsync(RoundEntity round, CancellationToken cancellationToken) + public async Task GenerateNewAsync(RoundEntity round, EntityCollection tournamentMatches, CancellationToken cancellationToken) { await Initialize(cancellationToken); @@ -162,7 +151,7 @@ internal async Task GenerateNewAsync(RoundEntity round, CancellationToken cancel // check whether the calculated date // is within the borders of round legs (if any) and is not marked as excluded - if (await IsDateUsable(matchDateTimeUtc, roundLeg, team, cancellationToken)) + if (IsDateUsable(matchDateTimeUtc, roundLeg, tournamentMatches, team)) { var av = new AvailableMatchDateEntity { @@ -175,7 +164,7 @@ internal async Task GenerateNewAsync(RoundEntity round, CancellationToken cancel IsGenerated = true }; - _generatedAvailableMatchDateEntities.Add(av); + _generatedAvailableDates.Add(av); } if (teamsWithSameVenue.Count > 1) @@ -189,11 +178,10 @@ internal async Task GenerateNewAsync(RoundEntity round, CancellationToken cancel } } - _logger.LogDebug("Generated {Count} UTC dates for HomeTeams:", _generatedAvailableMatchDateEntities.Count); - _logger.LogDebug("{Generated}\n", _generatedAvailableMatchDateEntities.Select(gen => (gen.HomeTeamId, gen.MatchStartTime))); + _logger.LogDebug("Generated {Count} UTC dates for HomeTeams:", _generatedAvailableDates.Count); + _logger.LogDebug("{Generated}\n", _generatedAvailableDates.Select(gen => (gen.HomeTeamId, gen.MatchStartTime)).ToList()); - // save to the persistent storage - // await _appDb.GenericRepository.SaveEntitiesAsync(_generatedAvailableMatchDateEntities, true, false, cancellationToken); + // Note: Generated dates are not saved to the database, but only used for the current run. } /// @@ -219,11 +207,13 @@ private List> GetListOfTeamsWithSameVenue(RoundEnti return listTeamsWithSameVenue; } - private async Task IsVenueOccupiedByMatchAsync(DateTimePeriod matchTime, long venueId, - CancellationToken cancellationToken) + private static bool IsVenueOccupiedByMatch(DateTimePeriod matchTime, long venueId, + IEnumerable tournamentMatches) { - return (await _appDb.VenueRepository.GetOccupyingMatchesAsync(venueId, matchTime, - _tenantContext.TournamentContext.MatchPlanTournamentId, cancellationToken)).Any(); + return tournamentMatches.Any(m => m.VenueId == venueId && + m is { IsComplete: false, PlannedStart: not null, PlannedEnd: not null } + && (m.PlannedStart <= matchTime.End && + matchTime.Start <= m.PlannedEnd)); // overlapping periods } private static bool IsDateWithinRoundLegDateTime(RoundLegEntity leg, DateTime queryDate) @@ -246,7 +236,8 @@ private static DateTime IncrementDateUntilDayOfWeek(DateTime date, DayOfWeek day /// but only for the team or round. /// /// - /// Same behavior as with . + /// Same conditions as with + /// which gets a s from the database, if one exists, or . /// /// Date to test, whether it is excluded. /// OR excluded on the round level. If , there is no round restriction. @@ -256,19 +247,19 @@ private bool IsExcludedDate(DateTime queryDate, long? roundId, long? teamId) { return // Excluded for the whole tournament... - _excludedMatchDateEntities.Any( + _excludedMatchDates.Any( excl => queryDate >= excl.DateFrom && queryDate <= excl.DateTo && excl.TournamentId == _tenantContext.TournamentContext.MatchPlanTournamentId && !excl.RoundId.HasValue && !excl.TeamId.HasValue) || // OR excluded for a round... - _excludedMatchDateEntities.Any( + _excludedMatchDates.Any( excl => queryDate >= excl.DateFrom && queryDate <= excl.DateTo && excl.TournamentId == _tenantContext.TournamentContext.MatchPlanTournamentId && excl.RoundId.HasValue && roundId.HasValue && excl.RoundId == roundId) || // OR excluded for a team - _excludedMatchDateEntities.Any( + _excludedMatchDates.Any( excl => queryDate >= excl.DateFrom && queryDate <= excl.DateTo && excl.TournamentId == _tenantContext.TournamentContext.MatchPlanTournamentId && excl.TeamId.HasValue && teamId.HasValue && excl.TeamId == teamId) @@ -277,7 +268,7 @@ private bool IsExcludedDate(DateTime queryDate, long? roundId, long? teamId) internal List GetGeneratedAndManualAvailableMatchDateDays(RoundLegEntity leg) { - var result = _generatedAvailableMatchDateEntities.Union(_availableMatchDateEntities) + var result = _generatedAvailableDates.Union(_availableDatesFromDb) .Where(gen => gen.TournamentId == _tenantContext.TournamentContext.MatchPlanTournamentId && gen.MatchStartTime >= leg.StartDateTime.Date && gen.MatchStartTime <= leg.EndDateTime.AddDays(1).AddSeconds(-1)) @@ -291,16 +282,16 @@ internal List GetGeneratedAndManualAvailableMatchDateDays(RoundLegEnti internal List GetGeneratedAndManualAvailableMatchDates(long homeTeamId, DateTimePeriod datePeriod, List? excludedDates) { - if (!(datePeriod.Start.HasValue && datePeriod.End.HasValue)) throw new ArgumentNullException(nameof(datePeriod)); + if (datePeriod is not { Start: not null, End: not null }) throw new ArgumentNullException(nameof(datePeriod)); - var result = _generatedAvailableMatchDateEntities.Union(_availableMatchDateEntities) + var result = _generatedAvailableDates.Union(_availableDatesFromDb) .Where(gen => gen.TournamentId == _tenantContext.TournamentContext.MatchPlanTournamentId && gen.HomeTeamId == homeTeamId && gen.MatchStartTime >= datePeriod.Start.Value.Date && gen.MatchStartTime <= datePeriod.End.Value.Date.AddDays(1).AddSeconds(-1)) .OrderBy(gen => gen.MatchStartTime); - if (excludedDates != null && excludedDates.Count > 0) + if (excludedDates is { Count: > 0 }) return result.Where(dates => !excludedDates.Contains(dates.MatchStartTime.Date)) .OrderBy(dates => dates.MatchStartTime).ToList(); diff --git a/TournamentManager/TournamentManager/Plan/ClearMatchDates.cs b/TournamentManager/TournamentManager/Plan/ClearMatchDates.cs deleted file mode 100644 index 4a04675f..00000000 --- a/TournamentManager/TournamentManager/Plan/ClearMatchDates.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace TournamentManager.Plan; - -[Flags] -public enum ClearMatchDates -{ - None = 0x00, - OnlyAutoGenerated = 0x01, - OnlyManual = 0x02, - All = 0x03 -} diff --git a/TournamentManager/TournamentManager/Plan/CombinationGroupOptimizer.cs b/TournamentManager/TournamentManager/Plan/CombinationGroupOptimizer.cs deleted file mode 100644 index 3daa27ae..00000000 --- a/TournamentManager/TournamentManager/Plan/CombinationGroupOptimizer.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Collections.ObjectModel; - -namespace TournamentManager.Plan; - -/// -/// Specifies the way matches are grouped. -/// -public enum CombinationGroupOptimization -{ - NoGrouping, - GroupWithAlternatingHomeGuest, - LeastGroupsPossible -} - -/// -/// The class will optimize match combinations in way, that groups of matches -/// can be played without overlapping teams (playing teams, referee teams). -/// -/// The type of the team objects. Objects must have IComparable implemented. -internal class CombinationGroupOptimizer -{ - private readonly TeamCombinationGroup _group; - - /// - /// Constructor. - /// - /// A collection of team combinations with type objects. - internal CombinationGroupOptimizer(TeamCombinationGroup group) - { - _group = group; - } - - /// - /// Groups the calculated team combinations for matches. in a way, that most matches - /// can be played in parallel. - /// - /// Optimization type for groups. Differences can be seen with an uneven number of teams. - /// Return a collection containing collections of team combinations. - internal Collection> GetBundledGroups(CombinationGroupOptimization optiType) - { - var combinationsQueue = new Queue>(_group.Count); - TeamCombinationGroup group; - var bundledGroups = new Collection>(); - - // create the FIFO queue - foreach (var combination in _group) - { - combinationsQueue.Enqueue(combination); - } - - switch (optiType) - { - case CombinationGroupOptimization.NoGrouping: - // every group contains a collection with only 1 match - while (combinationsQueue.Count > 0) - { - group = new TeamCombinationGroup { combinationsQueue.Dequeue() }; - bundledGroups.Add(group); - } - break; - - case CombinationGroupOptimization.GroupWithAlternatingHomeGuest: - group = new TeamCombinationGroup(); - while (combinationsQueue.Count > 0) - { - var combination = combinationsQueue.Dequeue(); - if (AnyTeamExistsInGroup(combination, group)) - { - bundledGroups.Add(group); - group = new TeamCombinationGroup(); - } - group.Add(combination); - } - if (group.Count > 0) - { - bundledGroups.Add(group); - } - break; - - case CombinationGroupOptimization.LeastGroupsPossible: - while (combinationsQueue.Count > 0) - { - var tmpGroup = new List>(); - tmpGroup.AddRange(combinationsQueue); - - group = new TeamCombinationGroup(); - foreach (var combination in tmpGroup) - { - if (!AnyTeamExistsInGroup(combination, group)) - { - group.Add(combinationsQueue.Dequeue()); - } - } - bundledGroups.Add(group); - } - break; - } - return bundledGroups; - } - - /// - /// Checks, whether one of the existing matches contains any of the two or three teams of a certain match. - /// - /// - /// - /// Returns true, one of the existing matches contains any of the two or three teams of a certain match, false otherwise. - private static bool AnyTeamExistsInGroup(TeamCombination combination, TeamCombinationGroup group) - { - var teams = new Stack(30); - foreach (var t in group) - { - teams.Push(t.HomeTeam); - teams.Push(t.GuestTeam); - teams.Push(t.Referee); - } - while (teams.Count > 0) - { - var team = teams.Pop(); - if (Comparer.Default.Compare(team, combination.HomeTeam) == 0 || - Comparer.Default.Compare(team, combination.GuestTeam) == 0 || - Comparer.Default.Compare(team, combination.Referee) == 0) - return true; - } - return false; - } -} diff --git a/TournamentManager/TournamentManager/Plan/ExcludeMatchDates.cs b/TournamentManager/TournamentManager/Plan/ExcludeMatchDates.cs index b0260a88..5a737c9e 100644 --- a/TournamentManager/TournamentManager/Plan/ExcludeMatchDates.cs +++ b/TournamentManager/TournamentManager/Plan/ExcludeMatchDates.cs @@ -10,9 +10,9 @@ namespace TournamentManager.Plan; /// /// This class manages excluded match dates. /// -public class ExcludeMatchDates +internal class ExcludeMatchDates { - private readonly AppDb _appDb; + private readonly IAppDb _appDb; private readonly ILogger _logger; /// @@ -41,12 +41,15 @@ public async Task GenerateExcludeDates(IExcludeDateImporter importer, long tourn RoundLegPeriodFields.TournamentId == tournamentId), cancellationToken); + _logger.LogDebug("Generating excluded dates for tournament {tournamentId} with {roundLegPeriods} round leg periods.", tournamentId, roundLegPeriods.Count); + var minDate = roundLegPeriods.Min(leg => leg.StartDateTime); var maxDate = roundLegPeriods.Max(leg => leg.EndDateTime); // remove all existing excluded dates for the tournament if (removeExisting) { + _logger.LogDebug("Removing existing excluded dates for tournament {tournamentId}.", tournamentId); var filter = new RelationPredicateBucket(ExcludeMatchDateFields.TournamentId == tournamentId); await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(ExcludeMatchDateEntity), filter, cancellationToken); @@ -54,6 +57,7 @@ await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(ExcludeMatchDa var excludedDates = new EntityCollection(); + _logger.LogDebug("Importing excluded dates from {minDate} to {maxDate} for tournament {tournamentId}.", tournamentId, minDate, maxDate); foreach (var record in importer.Import(new DateTimePeriod(minDate, maxDate))) { var entity = record.ToExcludeMatchDateEntity(); @@ -61,6 +65,7 @@ await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(ExcludeMatchDa excludedDates.Add(entity); } + _logger.LogDebug("Saving {excludedDates} excluded dates for tournament {tournamentId}.", excludedDates.Count, tournamentId); await _appDb.GenericRepository.SaveEntitiesAsync(excludedDates, false, false, cancellationToken); } } diff --git a/TournamentManager/TournamentManager/Plan/GuestRefereeAssigner.cs b/TournamentManager/TournamentManager/Plan/GuestRefereeAssigner.cs new file mode 100644 index 00000000..727ce968 --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/GuestRefereeAssigner.cs @@ -0,0 +1,24 @@ +namespace TournamentManager.Plan; + +/// +/// Assigns the referee to the guest participant. +/// +/// The type of the participant. +/// The type of the referee. +public class GuestRefereeAssigner : IRefereeAssigner where TP : struct, IEquatable where TR : struct, IEquatable +{ + public GuestRefereeAssigner(IList? _ = null) + { + } + + /// + /// The referee assignment type. + /// + public const RefereeType AssignmentType = RefereeType.Guest; + + /// + public TR? GetReferee((int Turn, TP Home, TP Guest) match) + { + return (TR?) (object) match.Guest; + } +} diff --git a/TournamentManager/TournamentManager/Plan/HomeRefereeAssigner.cs b/TournamentManager/TournamentManager/Plan/HomeRefereeAssigner.cs new file mode 100644 index 00000000..f3479b2a --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/HomeRefereeAssigner.cs @@ -0,0 +1,24 @@ +namespace TournamentManager.Plan; + +/// +/// Assigns the referee to the home participant. +/// +/// The type of the participant. +/// The type of the referee. +internal class HomeRefereeAssigner : IRefereeAssigner where TP : struct, IEquatable where TR : struct, IEquatable +{ + public HomeRefereeAssigner(IList? _) + { + } + + /// + /// The referee assignment type. + /// + public const RefereeType AssignmentType = RefereeType.Home; + + /// + public TR? GetReferee((int Turn, TP Home, TP Guest) match) + { + return (TR?) (object) match.Home; + } +} diff --git a/TournamentManager/TournamentManager/Plan/IRefereeAssigner.cs b/TournamentManager/TournamentManager/Plan/IRefereeAssigner.cs new file mode 100644 index 00000000..2550db8b --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/IRefereeAssigner.cs @@ -0,0 +1,21 @@ +namespace TournamentManager.Plan; + +/// +/// Interface for referee assigners. +/// +/// The type of the participant. +/// The type of the referee. +internal interface IRefereeAssigner where TP : struct, IEquatable where TR : struct, IEquatable +{ + /// + /// The referee assignment type. + /// + const RefereeType AssignmentType = RefereeType.None; + + /// + /// Returns the referee for the match. + /// + /// The match. + /// The referee for the match. + TR? GetReferee((int Turn, TP Home, TP Guest) match); +} diff --git a/TournamentManager/TournamentManager/Plan/LegType.cs b/TournamentManager/TournamentManager/Plan/LegType.cs new file mode 100644 index 00000000..c88b16f4 --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/LegType.cs @@ -0,0 +1,16 @@ +namespace TournamentManager.Plan; + +/// +/// Specifies the type of the leg to create match combinations of a round +/// +public enum LegType +{ + /// + /// The first leg. + /// + First = 1, + /// + /// The return leg. + /// + Return +} diff --git a/TournamentManager/TournamentManager/Plan/MatchCreator.cs b/TournamentManager/TournamentManager/Plan/MatchCreator.cs new file mode 100644 index 00000000..2cb8a56d --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/MatchCreator.cs @@ -0,0 +1,133 @@ +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; +using TournamentManager.MultiTenancy; +using TournamentManager.RoundRobin; + +namespace TournamentManager.Plan; + +/// +/// Class to create matches for a group of participants. +/// The round-robin system is applied, i.e. all participants in the group play each other. +/// +/// The participant type. +/// The referee type. +internal class MatchCreator where TP : struct, IEquatable where TR : struct, IEquatable +{ + private readonly ITenantContext _tenantContext; + private readonly ILogger> _logger; + private int _maxNumOfCombinations; + private readonly ParticipantCombinations _participantCombinationsFirstLeg = new(); + private readonly ParticipantCombinations _participantCombinationsReturnLeg = new(); + + /// + /// Constructor. + /// + /// The . + /// The logger. + public MatchCreator(ITenantContext tenantContext, ILogger> logger) + { + if (!typeof(TR).IsAssignableFrom(typeof(TP))) + throw new ArgumentException($"Type {typeof(TR).Name} must be assignable from {typeof(TP).Name}."); + + _tenantContext = tenantContext; + _logger = logger; + } + + /// + /// Sets the participants. + /// + /// + /// The current instance of the . + public MatchCreator SetParticipants(Collection participants) + { + Participants = participants; + return this; + } + + /// + /// Creates the match combinations for all participants. + /// + private void CreateCombinations(RefereeType refereeType) + { + if (Participants.Count < 2) + throw new InvalidOperationException("Round-robin system requires at least 2 participants."); + + if (Participants.Count < 3 && refereeType == RefereeType.OtherFromRound) + throw new ArgumentOutOfRangeException(nameof(refereeType), refereeType,@"Round-robin system with referee from round requires at least 3 participants."); + + _logger.LogDebug("Creating combinations for {participantCount} participants.", Participants.Count); + + _maxNumOfCombinations = Participants.Count * (Participants.Count - 1) / 2; + CombinationsPerLeg = Participants.Count - 1; + + _participantCombinationsFirstLeg.Clear(); + + var roundRobinMatches = GetRoundRobinSystem().GenerateMatches(); + _maxNumOfCombinations = roundRobinMatches.Count; + + // re-assign referee according to settings: + var referees = new List(); + referees.AddRange((IEnumerable) Participants); + + var refereeAssigner = RefereeAssigners.GetRefereeAssigner(refereeType, referees); + + for (var count = 0; count < _maxNumOfCombinations; count++) + { + var match = roundRobinMatches[count]; + var combination = new ParticipantCombination(match.Turn, match.Home, match.Guest, default); + combination.Referee = (TR?) (object?) refereeAssigner.GetReferee((combination.Turn, combination.Home, combination.Guest)); + + _participantCombinationsFirstLeg.Add(combination); + } + + CreateCombinationsReturnLeg(refereeType); + } + + private IRoundRobinSystem GetRoundRobinSystem() + { + return Participants.Count is >= 5 and <= 14 + ? new IdealRoundRobinSystem(Participants) + : new RoundRobinSystem(Participants); + } + + /// + /// Creates the return leg based on the previously created first leg + /// by swapping home / guest participants and assigning the referee. + /// + private void CreateCombinationsReturnLeg(RefereeType refereeType) + { + _participantCombinationsReturnLeg.Clear(); + + // re-assign referee according to settings: + var referees = new List(); + referees.AddRange((IEnumerable) Participants); + var refereeAssigner = RefereeAssigners.GetRefereeAssigner(refereeType, referees); + + foreach (var match in _participantCombinationsFirstLeg) + { + _participantCombinationsReturnLeg.Add(new ParticipantCombination(match.Turn, match.Guest, match.Home, (TR?) (object?) refereeAssigner.GetReferee((match.Turn, match.Guest, match.Home)))); + } + } + + /// + /// Gets all combinations for the given participants, using the round-robin system. + /// + /// First leg or return leg. + /// Return a collection of participant combinations. + public ParticipantCombinations GetCombinations(LegType legType) + { + CreateCombinations(_tenantContext.TournamentContext.RefereeRuleSet.RefereeType); + + return (legType == LegType.First) ? _participantCombinationsFirstLeg : _participantCombinationsReturnLeg; + } + + /// + /// Gets the number of matches per participant. + /// + public int CombinationsPerLeg { get; private set; } + + /// + /// Gets the participants. + /// + public Collection Participants { get; private set; } = new(); +} diff --git a/TournamentManager/TournamentManager/Plan/MatchDateClearOption.cs b/TournamentManager/TournamentManager/Plan/MatchDateClearOption.cs new file mode 100644 index 00000000..9a3ac98a --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/MatchDateClearOption.cs @@ -0,0 +1,25 @@ +namespace TournamentManager.Plan; + +/// +/// Specifies the options for clearing match dates. +/// +[Flags] +public enum MatchDateClearOption +{ + /// + /// No match dates are cleared. + /// + None = 0, + /// + /// Only auto-generated match dates are cleared. + /// + OnlyAutoGenerated = 1, + /// + /// Only manually added match dates are cleared. + /// + OnlyManual = 2, + /// + /// All match dates are cleared. + /// + All = 3 +} diff --git a/TournamentManager/TournamentManager/Plan/MatchPlanner.cs b/TournamentManager/TournamentManager/Plan/MatchPlanner.cs deleted file mode 100644 index 59a0a6d9..00000000 --- a/TournamentManager/TournamentManager/Plan/MatchPlanner.cs +++ /dev/null @@ -1,364 +0,0 @@ -using TournamentManager.DAL.EntityClasses; -using TournamentManager.DAL.HelperClasses; -using SD.LLBLGen.Pro.ORMSupportClasses; -using System.Collections.ObjectModel; -using Microsoft.Extensions.Logging; -using TournamentManager.MultiTenancy; - -namespace TournamentManager.Plan; - -/// -/// Generates fixtures for all teams of a tournament or round. -/// If the home team has no venue or home match time defined, it will only have away matches. -/// -public class MatchPlanner -{ - private readonly ITenantContext _tenantContext; - private readonly AppDb _appDb; - private static TournamentEntity _tournament = new(); - private readonly ILogger _logger; - private readonly AvailableMatchDates _availableMatchDates; - private readonly Axuno.Tools.DateAndTime.TimeZoneConverter _timeZoneConverter; - - private static bool AreEntitiesLoaded { get; set; } = false; - - /// - /// CTOR. - /// - /// - /// - /// - public MatchPlanner(ITenantContext tenantContext, - Axuno.Tools.DateAndTime.TimeZoneConverter timeZoneConverter, ILoggerFactory loggerFactory) - { - _tenantContext = tenantContext; - _appDb = tenantContext.DbContext.AppDb; - _timeZoneConverter = timeZoneConverter; - _availableMatchDates = new AvailableMatchDates(tenantContext, timeZoneConverter, loggerFactory.CreateLogger()); - _logger = loggerFactory.CreateLogger(); - } - - private async Task LoadEntitiesAsync(CancellationToken cancellationToken) - { - _tournament = await _appDb.TournamentRepository.GetTournamentEntityForMatchPlannerAsync( - _tenantContext.TournamentContext.MatchPlanTournamentId, cancellationToken) ?? throw new InvalidOperationException($"Could not load entity {nameof(TournamentEntity)}"); - AreEntitiesLoaded = true; - } - - - - public async Task GenerateAvailableMatchDatesAsync(ClearMatchDates clearMatchDates, RoundEntity round, - CancellationToken cancellationToken) - { - _ = await _availableMatchDates.ClearAsync(clearMatchDates, cancellationToken); - if (!AreEntitiesLoaded) await LoadEntitiesAsync(cancellationToken); - await _availableMatchDates.GenerateNewAsync(round, cancellationToken); - } - - /// - /// Generates tournament match combinations for the Round Robin system, - /// assigns optimized match dates and stores the matches to - /// the persistent storage. - /// - public async Task GenerateFixturesForTournament(bool keepExisting, CancellationToken cancellationToken) - { - if (!AreEntitiesLoaded) await LoadEntitiesAsync(cancellationToken); - - if (_appDb.MatchRepository.AnyCompleteMatchesExist(_tenantContext.TournamentContext.MatchPlanTournamentId)) - throw new InvalidOperationException("Completed matches exist for this tournament. Generating fixtures aborted."); - - foreach (var round in _tournament.Rounds) - await GenerateFixturesForRound(round, keepExisting, cancellationToken); - } - - /// - /// Generates round match combinations for the Round Robin system, - /// assigns optimized match dates and stores the matches to - /// the persistent storage. - /// - public async Task GenerateFixturesForRound(RoundEntity round, bool keepExisting, - CancellationToken cancellationToken) - { - if (!AreEntitiesLoaded) await LoadEntitiesAsync(cancellationToken); - - if (_appDb.MatchRepository.AnyCompleteMatchesExist(round)) - throw new InvalidOperationException($"Completed matches exist for round '{round.Id}'. Generating fixtures aborted."); - - // generated matches will be stored here - var roundMatches = new EntityCollection(); - - if (keepExisting) - { - roundMatches = _appDb.MatchRepository.GetMatches(round); - } - else - { - var bucket = new RelationPredicateBucket(new PredicateExpression( - new FieldCompareRangePredicate(MatchFields.RoundId, null, false, new[] {round.Id}))); - await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(MatchEntity), bucket, - cancellationToken); - } - - await GenerateAvailableMatchDatesAsync(ClearMatchDates.OnlyAutoGenerated, round, cancellationToken); - - // get the team ids because TeamEntity lacks IComparable - // and cannot be used directly - var teams = new Collection(round.TeamCollectionViaTeamInRound.Select(t => t.Id).ToList()); - - // now calculate matches for each leg of a round - foreach (var roundLeg in round.RoundLegs) - { - // build up match combinations for the teams of round - var roundRobin = new RoundRobinSystem(teams); - var bundledGroups = - roundRobin.GetBundledGroups(RefereeType.HomeTeam, - roundLeg.SequenceNo % 2 == 1 ? LegType.First : LegType.Return, - CombinationGroupOptimization.GroupWithAlternatingHomeGuest); - - /* - * Special treatment for teams which do not have home matches - */ - var teamsWithoutHomeMatches = GetTeamsWithoutHomeMatches(round).ToList(); - - foreach (var teamCombinationGroup in bundledGroups) - foreach (var combination in teamCombinationGroup) - { - if (!teamsWithoutHomeMatches.Contains(combination.HomeTeam)) continue; - - _logger.LogDebug("Team cannot have home matches - {TeamId}", combination.HomeTeam); - - // swap home and guest team, keep referee unchanged - (combination.HomeTeam, combination.GuestTeam) = (combination.GuestTeam, combination.HomeTeam); - } - - /* - * Assign desired from/to dates to bundled groups for later orientation - * in which period matches should take place - */ - AssignRoundDatePeriods(roundLeg, bundledGroups); - - if (bundledGroups.Any(g => !g.DateTimePeriod.Start.HasValue)) - throw new InvalidOperationException( - "Not all bundled groups got a date period assigned. Probably not enough dates available for assignment."); - - // process each team combination (match) that shall take place in the same week (if possible) - foreach (var teamCombinationGroup in bundledGroups) - { - // get match dates for every combination of a group. - // matches in the same teamCombinationGroup can even take place on the same day. - // matchDates contains calculated dates in the same order as combinations, - // so the index can be used for both. - var availableDates = GetMatchDates(roundLeg, teamCombinationGroup, roundMatches); - _logger.LogDebug("Available dates for combination: {dates}", string.Join(", ", availableDates.OrderBy(bd => bd?.MatchStartTime).Select(bd => bd?.MatchStartTime.ToShortDateString())).TrimEnd(',', ' ')); - - for (var index = 0; index < teamCombinationGroup.Count; index++) - { - var combination = teamCombinationGroup[index]; - - // If existing matches were loaded from database, we have to skip such combinations! - // Note: Home team and guest team of combinations could have been swapped for TeamsWithoutHomeMatches - if (roundMatches.Any(rm => - rm.HomeTeamId == combination.HomeTeam && rm.GuestTeamId == combination.GuestTeam && - rm.LegSequenceNo == roundLeg.SequenceNo || rm.GuestTeamId == combination.HomeTeam && - rm.HomeTeamId == combination.GuestTeam && rm.LegSequenceNo == roundLeg.SequenceNo)) - continue; - - var match = new MatchEntity - { - HomeTeamId = combination.HomeTeam, - GuestTeamId = combination.GuestTeam, - RefereeId = combination.Referee, - PlannedStart = availableDates[index] != null ? availableDates[index]!.MatchStartTime : default(DateTime?), - PlannedEnd = availableDates[index] != null ? availableDates[index]!.MatchStartTime - .Add(_tenantContext.TournamentContext.FixtureRuleSet.PlannedDurationOfMatch) : default(DateTime?), - VenueId = availableDates[index] != null - ? availableDates[index]!.VenueId - // take over the venue stored in the team entity (may also be null!) - : _tournament.Rounds[_tournament.Rounds.FindMatches(RoundFields.Id == roundLeg.RoundId).First()].TeamCollectionViaTeamInRound.First(t => t.Id == combination.HomeTeam).VenueId, - RoundId = round.Id, - IsComplete = false, - LegSequenceNo = roundLeg.SequenceNo, - ChangeSerial = 0, - Remarks = string.Empty - }; - - _logger.LogDebug("Fixture: {HomeTeam} - {GuestTeam}: {PlannedStart}", match.HomeTeamId, match.GuestTeamId, match.PlannedStart); - - roundMatches.Add(match); - } - } - } - - // save the matches for the group - await _appDb.GenericRepository.SaveEntitiesAsync(roundMatches, true, false, cancellationToken); - - await _availableMatchDates.ClearAsync(ClearMatchDates.OnlyAutoGenerated, cancellationToken); - } - - private static List GetOccupiedMatchDates(TeamCombination combination, - IEnumerable matches) - { - return (from match in matches - where - match.PlannedStart.HasValue && match.PlannedEnd.HasValue && - (match.HomeTeamId == combination.HomeTeam || match.GuestTeamId == combination.GuestTeam || - match.GuestTeamId == combination.HomeTeam || match.GuestTeamId == combination.GuestTeam) - select match.PlannedStart!.Value.Date).ToList(); - } - - - private List GetMatchDates(RoundLegEntity roundLeg, - TeamCombinationGroup teamCombinationGroup, EntityCollection groupMatches) - { - // here the resulting match dates are stored: - var matchDatePerCombination = new List(); - - // these are possible date alternatives per combination: - var matchDates = new List>(); - - for (var index = 0; index < teamCombinationGroup.Count; index++) - { - var combination = teamCombinationGroup[index]; - - var availableDates = _availableMatchDates.GetGeneratedAndManualAvailableMatchDates(combination.HomeTeam, - teamCombinationGroup.DateTimePeriod, GetOccupiedMatchDates(combination, groupMatches)); - // initialize MinTimeDiff for the whole list - availableDates.ForEach(amd => amd.MinTimeDiff = TimeSpan.MaxValue); - if (availableDates.Count == 0) - { - availableDates = _availableMatchDates.GetGeneratedAndManualAvailableMatchDates(combination.HomeTeam, - new DateTimePeriod(roundLeg.StartDateTime, roundLeg.EndDateTime), - GetOccupiedMatchDates(combination, groupMatches)); - } - - matchDates.Add(availableDates); - -#if DEBUG - // Check whether there is a match of this combination - var lastMatchOfCombination = groupMatches.OrderBy(gm => gm.PlannedStart).LastOrDefault(gm => - gm.HomeTeamId == combination.HomeTeam || gm.GuestTeamId == combination.GuestTeam); - if (lastMatchOfCombination != null) - { - _logger.LogDebug("Last match date found for home team '{homeTeam}' and guest team '{guestTeam}' is '{plannedStart}'", combination.HomeTeam, combination.GuestTeam, lastMatchOfCombination.PlannedStart?.ToShortDateString() ?? "none"); - } - else - { - _logger.LogDebug("No last match found for home team '{homeTeam}' and guest team '{guestTeam}'", combination.HomeTeam, combination.GuestTeam); - } -#endif - } - - // we can't proceed without any match dates found - if (matchDates.Count == 0) return matchDatePerCombination; - - // only 1 match date found, so optimization is not possible - // and the following "i-loop" will be skipped - if (matchDates.Count == 1) - { - matchDatePerCombination.Add(matchDates[0][0]); - return matchDatePerCombination; - } - - // cross-compute the number of dates between between group pairs. - // goal: found match dates should be as close together as possible - - // start with 1st dates, end with last but one dates - for (var i = 0; i < matchDates.Count - 1; i++) - { - // start with 2nd dates, end with last dates - for (var j = 1; j < matchDates.Count; j++) - { - // compare each date in the first list... - foreach (var dates1 in matchDates[i]) - { - // ... with the dates in the second list - foreach (var dates2 in matchDates[j]) - { - var daysDiff = Math.Abs((dates1.MatchStartTime.Date - dates2.MatchStartTime.Date).Days); - - // save minimum dates found for later reference - if (daysDiff < dates1.MinTimeDiff.Days) - dates1.MinTimeDiff = new TimeSpan(daysDiff, 0, 0, 0); - - if (daysDiff < dates2.MinTimeDiff.Days) - dates2.MinTimeDiff = new TimeSpan(daysDiff, 0, 0, 0); - } // end dates2 - } // end dates1 - } // end j - - // get the date that has least distance to smallest date in other group(s) - // Note: If no match dates could be determined for a team, bestDate will be null. - var bestDate = matchDates[i].Where(md => md.MinTimeDiff == matchDates[i].Min(d => d.MinTimeDiff)) - .OrderBy(md => md.MinTimeDiff).FirstOrDefault(); - matchDatePerCombination.Add(bestDate); - - // process the last combination - - // in case comparisons took place, - // now the "j-loop" group is not processed yet: - if (i + 1 >= matchDates.Count - 1) - { - bestDate = matchDates[^1].Where(md => md.MinTimeDiff == matchDates[^1].Min(d => d.MinTimeDiff)) - .MinBy(md => md.MinTimeDiff); - // the last "j-increment" is always the same as "matchDates[^1]" (loop condition) - matchDatePerCombination.Add(bestDate); - } - } // end i - - return matchDatePerCombination; - } - - /// - /// Date periods are assigned to bundled groups purely mathematically, - /// spreading match dates equally across the 's and . - /// - /// - /// - private void AssignRoundDatePeriods(RoundLegEntity roundLeg, - Collection> bundledGroups) - { - var allMatchDaysOfRound = - _availableMatchDates - .GetGeneratedAndManualAvailableMatchDateDays( - roundLeg); // _appDb.AvailableMatchDateRepository.GetAvailableMatchDateDays(roundLeg); - - var periodDaysCount = allMatchDaysOfRound.Count / (bundledGroups.Count + 1); - - var start = 0; - var index = 0; - - _logger.LogDebug("*** Round: {roundName} - RoundLeg: {legDescription}\n", roundLeg.Round.Name, roundLeg.Description); - while (start < allMatchDaysOfRound.Count && index < bundledGroups.Count) - { - //TODO: There could be a remainder of days because of integer division! - var end = start + periodDaysCount < allMatchDaysOfRound.Count - ? start + periodDaysCount - : allMatchDaysOfRound.Count - 1; - bundledGroups[index].DateTimePeriod = - new DateTimePeriod(allMatchDaysOfRound[start].Date, allMatchDaysOfRound[end].Date); - - _logger.LogDebug("Bundle date period: From={from}, To={to}, {days} days", - bundledGroups[index].DateTimePeriod.Start?.ToShortDateString(), - bundledGroups[index].DateTimePeriod.End?.ToShortDateString(), - (bundledGroups[index].DateTimePeriod.End - bundledGroups[index].DateTimePeriod.Start) - ?.Days); - - start = end + 1; - index++; - } - } - - /// - /// Gets teams IDs for the where no home venue or no home match time set for a team. - /// - private static IEnumerable GetTeamsWithoutHomeMatches(RoundEntity round) - { - foreach (var team in round.TeamCollectionViaTeamInRound) - { - if (team.VenueId == null || team.MatchTime == null || team.MatchDayOfWeek == null) - { - yield return team.Id; - } - } - } -} diff --git a/TournamentManager/TournamentManager/Plan/MatchScheduler.cs b/TournamentManager/TournamentManager/Plan/MatchScheduler.cs new file mode 100644 index 00000000..0131ad64 --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/MatchScheduler.cs @@ -0,0 +1,509 @@ +using TournamentManager.DAL.EntityClasses; +using TournamentManager.DAL.HelperClasses; +using SD.LLBLGen.Pro.ORMSupportClasses; +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; +using TournamentManager.MultiTenancy; + +namespace TournamentManager.Plan; + +/// +/// Schedules fixtures for all participants of a tournament or round. +/// If the home participant has no venue or home match date and time are defined, it will only have away matches. +/// +internal class MatchScheduler +{ + private readonly ITenantContext _tenantContext; + private readonly IAppDb _appDb; + private TournamentEntity _tournament = new(); + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly AvailableMatchDates _availableMatchDates; + private bool _areEntitiesLoaded; + + /// + /// CTOR. + /// + /// + /// + /// + public MatchScheduler(ITenantContext tenantContext, + Axuno.Tools.DateAndTime.TimeZoneConverter timeZoneConverter, ILoggerFactory loggerFactory) + { + _tenantContext = tenantContext; + _appDb = tenantContext.DbContext.AppDb; + _availableMatchDates = new AvailableMatchDates(tenantContext, timeZoneConverter, loggerFactory.CreateLogger()); + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + } + + private async Task LoadEntitiesAsync(CancellationToken cancellationToken) + { + if (_areEntitiesLoaded) return; + _tournament = await _appDb.TournamentRepository.GetTournamentEntityForMatchSchedulerAsync( + _tenantContext.TournamentContext.MatchPlanTournamentId, cancellationToken) ?? throw new InvalidOperationException($"Could not load entity {nameof(TournamentEntity)}"); + _areEntitiesLoaded = true; + } + + private async Task GenerateAvailableMatchDatesAsync(MatchDateClearOption clearMatchDates, RoundEntity round, + EntityCollection tournamentMatches, CancellationToken cancellationToken) + { + await LoadEntitiesAsync(cancellationToken); + _ = await _availableMatchDates.ClearAsync(clearMatchDates, cancellationToken); + await _availableMatchDates.GenerateNewAsync(round, tournamentMatches, cancellationToken); + } + + /// + /// Generates tournament match combinations for the Round Robin system, + /// assigns optimized match dates and stores the matches to + /// the persistent storage. + /// + public async Task ScheduleFixturesForTournament(bool keepExisting, CancellationToken cancellationToken) + { + await LoadEntitiesAsync(cancellationToken); + + if (await _appDb.MatchRepository.AnyCompleteMatchesExistAsync(_tenantContext.TournamentContext.MatchPlanTournamentId, cancellationToken)) + throw new InvalidOperationException("Completed matches exist for this tournament. Generating fixtures aborted."); + + foreach (var round in _tournament.Rounds) + await ScheduleFixturesForRound(round, keepExisting, cancellationToken); + } + + /// + /// Generates round match combinations for the round-robin system, + /// assigns optimized match dates and stores the matches to + /// the persistent storage. + /// + public async Task ScheduleFixturesForRound(RoundEntity round, bool keepExisting, + CancellationToken cancellationToken) + { + await LoadEntitiesAsync(cancellationToken); + + if (await _appDb.MatchRepository.AnyCompleteMatchesExistAsync(round, cancellationToken)) + throw new InvalidOperationException($"Completed matches exist for round '{round.Id}'. Generating fixtures aborted."); + + // We load ALL tournament matches (including those that were saved previously), + // so that we can check for venues occupied by existing matches in memory. + var tournamentMatches = await GetOrCreateTournamentMatches(round, keepExisting, cancellationToken); + + await GenerateAvailableMatchDatesAsync(MatchDateClearOption.OnlyAutoGenerated, round, tournamentMatches, cancellationToken); + + var teams = new Collection(round.TeamCollectionViaTeamInRound.Select(t => t.Id).ToList()); + + // now calculate matches for each leg of a round + foreach (var roundLeg in round.RoundLegs) + { + var combinations = CreateCombinations(teams, roundLeg); + + HandleTeamsWithoutHomeMatches(round, combinations); + + /* + * Assign desired time periods for the round turns. + * These periods are used later to calculate the match dates for the turns. + */ + AssignTurnDatePeriods(roundLeg, combinations); + + // Team combinations (matches) for each turn can take place at the same time. + // We assign the match dates based on the turn combinations' TurnDateTimePeriods + foreach (var turn in combinations.GetTurns()) + { + SetMatchDates(round, roundLeg, turn, combinations, tournamentMatches); + } + } + + // tournamentMatches contains all matches of the tournament + // (including matches of other rounds that were already saved). + // This method saves only new or modified matches. + OnBeforeSave?.Invoke(this, tournamentMatches); + await _appDb.GenericRepository.SaveEntitiesAsync(tournamentMatches, true, false, cancellationToken); + OnAfterSave?.Invoke(this, tournamentMatches); + + await _availableMatchDates.ClearAsync(MatchDateClearOption.OnlyAutoGenerated, cancellationToken); + } + + /// + /// This event is raised before the matches are saved to the database. + /// It can be used to identify the modified or new matches that will be saved. + /// + internal EventHandler>? OnBeforeSave; + + /// + /// This event is raised after the matches are saved to the database. + /// The entity collection was re-fetched after save. + /// + internal EventHandler>? OnAfterSave; + + /// + /// Sets the match dates for the given in and . + /// + /// + /// + /// + /// + /// Contains the scheduled matches. + private void SetMatchDates(RoundEntity round, RoundLegEntity roundLeg, + int turn, ParticipantCombinations combinations, + EntityCollection tournamentMatches) + { + // Get the selected turn combinations for the given turn. + var selectedTurnCombinations = combinations.GetCombinations(turn).ToList(); + + // Get match dates for every combination of a turn. + // Matches in the same turnCombinations can even take place at the same time. + var datesFound = GetMatchDatesForTurn(roundLeg, turn, combinations, tournamentMatches); + _logger.LogDebug("Found dates for combination: {dates}", + string.Join(", ", + datesFound.OrderBy(bd => bd?.MatchStartTime) + .Select(bd => bd?.MatchStartTime.ToShortDateString() ?? "(null)")) + .Trim(',', ' ')); + + // datesFound contains calculated dates in the same sequence as turn combinations, + // so the index can be used for both. + for (var index = 0; index < selectedTurnCombinations.Count; index++) + { + var combination = selectedTurnCombinations[index]; + + // If existing matches were loaded from database, we have to skip such combinations! + // Note: Home team and guest team of combinations could have been swapped for TeamsWithoutHomeMatches + if (tournamentMatches.Any(rm => + (rm.HomeTeamId == combination.Home && rm.GuestTeamId == combination.Guest && + rm.LegSequenceNo == roundLeg.SequenceNo) || + (rm.GuestTeamId == combination.Home && rm.HomeTeamId == combination.Guest && + rm.LegSequenceNo == roundLeg.SequenceNo))) + continue; + + var match = new MatchEntity + { + HomeTeamId = combination.Home, + GuestTeamId = combination.Guest, + RefereeId = combination.Referee, + PlannedStart = datesFound[index] != null ? datesFound[index]!.MatchStartTime : default(DateTime?), + PlannedEnd = datesFound[index] != null + ? datesFound[index]!.MatchStartTime + .Add(_tenantContext.TournamentContext.FixtureRuleSet.PlannedDurationOfMatch) + : default(DateTime?), + VenueId = datesFound[index] != null + ? datesFound[index]!.VenueId + // take over the venue stored in the team entity (may also be null!) + : _tournament.Rounds[_tournament.Rounds.FindMatches(RoundFields.Id == roundLeg.RoundId).First()] + .TeamCollectionViaTeamInRound.First(t => t.Id == combination.Home).VenueId, + RoundId = round.Id, + IsComplete = false, + LegSequenceNo = roundLeg.SequenceNo, + ChangeSerial = 0, + Remarks = string.Empty + }; + + tournamentMatches.Add(match); + _logger.LogDebug("Fixture completed: {HomeTeam} - {GuestTeam}: {PlannedStart}", match.HomeTeamId, + match.GuestTeamId, match.PlannedStart); + } + } + + /// + /// Creates round-robin combinations for the given and . + /// + /// + /// + /// The round-robin combinations for the given and . + private ParticipantCombinations CreateCombinations(Collection teams, RoundLegEntity roundLeg) + { + // build up match combinations for the teams of round + var matchCreator = new MatchCreator(_tenantContext, _loggerFactory.CreateLogger>()); + var combinations = + matchCreator.SetParticipants(teams).GetCombinations( + roundLeg.SequenceNo % 2 == 1 ? LegType.First : LegType.Return); + return combinations; + } + + /// + /// Gets the existing matches for the given of a . + /// If is true, the existing matches are returned. + /// If is false, the existing matches of the round are deleted before returning tournament matches. + /// + /// + /// + /// + /// The existing matches for the given , depending on . + private async Task> GetOrCreateTournamentMatches(RoundEntity round, bool keepExistingMatches, CancellationToken cancellationToken) + { + if (!keepExistingMatches) + { + // delete existing matches of the round from storage + // before load tournament matches + var bucket = new RelationPredicateBucket(new PredicateExpression( + new FieldCompareRangePredicate(MatchFields.RoundId, null, false, new[] { round.Id }))); + await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(MatchEntity), bucket, + cancellationToken); + } + + var tournamentMatches = await _appDb.MatchRepository.GetMatches(round.TournamentId!.Value, cancellationToken); + return tournamentMatches; + } + + /// + /// Special treatment for teams which do not have home matches (no venue or no home weekday or time assigned): + /// Swap home and guest team (keep referee unchanged) + /// + /// The the belong to. + /// The combinations for the . + private void HandleTeamsWithoutHomeMatches(RoundEntity round, ParticipantCombinations combinations) + { + var teamsWithoutHomeMatches = GetTeamsWithoutHomeMatches(round).ToList(); + + foreach (var combination in combinations) + { + if (!teamsWithoutHomeMatches.Contains(combination.Home)) continue; + + _logger.LogDebug("Team cannot have home matches - {TeamId}. Swap with guest team.", combination.Home); + + // swap home and guest team (keep referee unchanged) + combination.SwapHomeGuest(); + } + } + + /// + /// Gets the match dates that are occupied (i.e. not available) for + /// the given and . + /// + /// + /// + /// The match dates that are occupied (i.e. not available). + private static List GetOccupiedMatchDates(ParticipantCombination combination, + IEnumerable matches) + { + return (from match in matches + where + match.PlannedStart.HasValue && match.PlannedEnd.HasValue && + (match.HomeTeamId == combination.Home || match.HomeTeamId == combination.Guest || + match.GuestTeamId == combination.Home || match.GuestTeamId == combination.Guest) + select match.PlannedStart!.Value.Date).ToList(); + } + + /// + /// Gets a lists of available match dates for the given of the . + /// + /// + /// + /// + /// + /// A lists of available match dates. + private List GetMatchDatesForTurn(RoundLegEntity roundLeg, int turn, + ParticipantCombinations combinations, EntityCollection roundMatches) + { + var turnCombinations = combinations.GetCombinations(turn).ToList(); + + // These are possible date alternatives per combination: + // Outer list: One item per combination + // Inner list: The list of date alternatives for the combination + var matchDates = new List>(); + + for (var index = 0; index < turnCombinations.Count; index++) + { + var turnCombinationsPeriod = combinations.TurnDateTimePeriods[turn]!.Value; + var combination = turnCombinations[index]; + + var availableDatesForCombination = _availableMatchDates.GetGeneratedAndManualAvailableMatchDates(combination.Home, + turnCombinationsPeriod, GetOccupiedMatchDates(combination, roundMatches)); + + // If no dates could be found, we extend the date period by 7 days (-3/+4) in both directions + if (availableDatesForCombination.Count == 0) + { + _logger.LogDebug("No free dates {from} - {to} found for {home} - {guest}: {period} * will be extended by 7 days in both directions.", + turnCombinationsPeriod.Start, turnCombinationsPeriod.End, combination.Home, combination.Guest, + turnCombinationsPeriod); + // The extension applies only to the current combination. + // We ensure that the extension does not exceed the round leg's date period. + turnCombinationsPeriod.Start = new[] { roundLeg.StartDateTime, turnCombinationsPeriod.Start!.Value.AddDays(-4) }.Max(); + turnCombinationsPeriod.End = new[] { roundLeg.EndDateTime, turnCombinationsPeriod.End!.Value.AddDays(+3) }.Min(); + + availableDatesForCombination = _availableMatchDates.GetGeneratedAndManualAvailableMatchDates(combination.Home, + turnCombinationsPeriod, GetOccupiedMatchDates(combination, roundMatches)); + } + + // initialize MinTimeDiff for the whole list + availableDatesForCombination.ForEach(amd => amd.MinTimeDiff = TimeSpan.MaxValue); + +#if DEBUG + // Get the last match for at least one of the teams, if any + var lastMatchOfCombination = roundMatches.OrderBy(gm => gm.PlannedStart).LastOrDefault(gm => + gm.HomeTeamId == combination.Home || gm.GuestTeamId == combination.Guest); + if (lastMatchOfCombination != null) + { + _logger.LogDebug("Last match date for home team '{homeTeam}' or guest team '{guestTeam}' is '{plannedStart}'", combination.Home, combination.Guest, lastMatchOfCombination.PlannedStart?.ToShortDateString() ?? "none"); + } + else + { + _logger.LogDebug("No matches yet for home team '{homeTeam}' or guest team '{guestTeam}'", combination.Home, combination.Guest); + } +#endif + // If no dates could be found, the date will be set to null. + if (availableDatesForCombination.Count == 0) + { + _logger.LogDebug( + "No free dates {from} - {to} found for {home } - {guest}: {period} * will be set to NULL.", + turnCombinationsPeriod.Start, turnCombinationsPeriod.End, combination.Home, combination.Guest, + turnCombinationsPeriod); + } + + // We have to add the list of available dates even if it is empty. + matchDates.Add(availableDatesForCombination); + } + + return matchDates.Count switch { + // We can't proceed without any match dates found + 0 => new List(), + // Only 1 match date found, so optimization is not possible + // and FindBestDate() would throw an exception + 1 => new List { matchDates[0][0] }, + _ => FindBestDatePerCombination(matchDates) + }; + } + + /// + /// Finds the best match date for each combination from a list of available match dates. + /// The best match is the date that has the smallest distance to the smallest date in the other turn(s). + /// If no match dates could be determined for a team, bestDate will be set to null. + /// + /// This method is optimizing across all combinations of all turns. + /// + /// + private static List FindBestDatePerCombination(List> availableMatchDates) + { + var bestMatchDatePerCombination = new List(); + + // Cross-compute the number of dates between matches of a turn. + // Goal: Found match dates are as close to each other as possible + + // start with 1st dates, end with last but one dates + for (var i = 0; i < availableMatchDates.Count - 1; i++) + { + // start with 2nd dates, end with last dates + for (var j = 1; j < availableMatchDates.Count; j++) + { + // compare each date in the first list... + foreach (var dates1 in availableMatchDates[i]) + { + // ... with the dates in the second list + foreach (var dates2 in availableMatchDates[j]) + { + var daysDiff = Math.Abs((dates1.MatchStartTime.Date - dates2.MatchStartTime.Date).Days); + + // save minimum dates found for later reference + if (daysDiff < dates1.MinTimeDiff.Days) + dates1.MinTimeDiff = new TimeSpan(daysDiff, 0, 0, 0); + + if (daysDiff < dates2.MinTimeDiff.Days) + dates2.MinTimeDiff = new TimeSpan(daysDiff, 0, 0, 0); + } // end dates2 + } // end dates1 + } // end j + + // Get the date that has the smallest distance to the smallest date in the other turn(s). + // Note: If no match dates could be determined for a team, bestDate will be null. + var bestDate = availableMatchDates[i] + .Where(md => md.MinTimeDiff == availableMatchDates[i] + .Min(d => d.MinTimeDiff)) + .MinBy(md => md.MinTimeDiff); + bestMatchDatePerCombination.Add(bestDate); + + // process the last combination + + // in case comparisons took place, + // now the "j-loop" group is not processed yet: + if (i + 1 >= availableMatchDates.Count - 1) + { + bestDate = availableMatchDates[^1] + .Where(md => md.MinTimeDiff == availableMatchDates[^1]. + Min(d => d.MinTimeDiff)) + .MinBy(md => md.MinTimeDiff); + // the last "j-increment" is always the same as "matchDates[^1]" (loop condition) + bestMatchDatePerCombination.Add(bestDate); + } + } // end i + + // returns the best match date found per combination, + // so the number of elements is the same as the number of combinations + return bestMatchDatePerCombination; + } + + /// + /// Desired s are assigned to round turns mathematically, + /// spreading match dates equally across the 's and . + /// These periods are used later to calculate the match dates for the turns. + /// + /// The to use. + /// The combinations where the will be filled. + /// Throws if not all round turns got a date period assigned. + private void AssignTurnDatePeriods(RoundLegEntity roundLeg, + ParticipantCombinations combinations) + { + var allMatchDaysOfRound = + _availableMatchDates + .GetGeneratedAndManualAvailableMatchDateDays(roundLeg); + + var periodDaysCount = allMatchDaysOfRound.Count / (combinations.GetTurns().Count() + 1); + + _logger.LogDebug("*** Round: {roundName} - RoundLeg: {legDescription}\n", roundLeg.Round.Name, roundLeg.Description); + + // Initialize the dictionary with the turns and date empty periods + combinations.TurnDateTimePeriods.Clear(); + foreach (var turn in combinations.GetTurns()) + { + combinations.TurnDateTimePeriods.Add(turn, null); + } + + var start = 0; + var index = 0; + while (start < allMatchDaysOfRound.Count && index < combinations.TurnDateTimePeriods.Count) + { + var end = start + periodDaysCount < allMatchDaysOfRound.Count + ? start + periodDaysCount + : allMatchDaysOfRound.Count - 1; + + // Get the key of the dictionary entry at the current index + var key = combinations.TurnDateTimePeriods.Keys.ElementAt(index); + combinations.TurnDateTimePeriods[key] = + new DateTimePeriod(allMatchDaysOfRound[start].Date, allMatchDaysOfRound[end].Date); + + // There could be a remainder of days because of integer division. + // If there is a gap between the end of the previous turn and the start of the current turn, + // we adjust the start date of the current turn to the end date of the previous turn + 1 day. + // Note: The keys (turn numbers) are one-based. + if (key > 1 && combinations.TurnDateTimePeriods[key]?.Start != + combinations.TurnDateTimePeriods[key - 1]?.End?.AddDays(1)) + { + combinations.TurnDateTimePeriods[key] = + new DateTimePeriod(combinations.TurnDateTimePeriods[key - 1]?.End?.AddDays(1), + combinations.TurnDateTimePeriods[key]?.End); + } + + _logger.LogDebug("Turn #{turn} date period: From={from}, To={to}, {days} days", + key, combinations.TurnDateTimePeriods[key]?.Start?.ToShortDateString(), + combinations.TurnDateTimePeriods[key]?.End?.ToShortDateString(), + (combinations.TurnDateTimePeriods[key]?.End - combinations.TurnDateTimePeriods[key]?.Start) + ?.Days); + + start = end + 1; + index++; + } + + if (combinations.TurnDateTimePeriods.Values.Any(p => p is null)) + throw new InvalidOperationException( + "Not all round turns got a date period assigned. Probably not enough dates available for assignment."); + } + + /// + /// Gets s for the where no home venue or no home match time set for a team. + /// + private static IEnumerable GetTeamsWithoutHomeMatches(RoundEntity round) + { + foreach (var team in round.TeamCollectionViaTeamInRound) + { + if (team.VenueId == null || team.MatchTime == null || team.MatchDayOfWeek == null) + { + yield return team.Id; + } + } + } +} diff --git a/TournamentManager/TournamentManager/Plan/NoRefereeAssigner.cs b/TournamentManager/TournamentManager/Plan/NoRefereeAssigner.cs new file mode 100644 index 00000000..b7c589b2 --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/NoRefereeAssigner.cs @@ -0,0 +1,24 @@ +namespace TournamentManager.Plan; + +/// +/// Assigns the default referee for the match. +/// +/// The type of the participant. +/// The type of the referee. +internal class NoRefereeAssigner : IRefereeAssigner where TP : struct, IEquatable where TR : struct, IEquatable +{ + public NoRefereeAssigner(IList? _) + { + } + + /// + /// The referee assignment type. + /// + public const RefereeType AssignmentType = RefereeType.None; + + /// + public TR? GetReferee((int Turn, TP Home, TP Guest) match) + { + return default; + } +} diff --git a/TournamentManager/TournamentManager/Plan/OtherFromRoundRefereeAssigner.cs b/TournamentManager/TournamentManager/Plan/OtherFromRoundRefereeAssigner.cs new file mode 100644 index 00000000..ce7fad0f --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/OtherFromRoundRefereeAssigner.cs @@ -0,0 +1,65 @@ +namespace TournamentManager.Plan; + +/// +/// Assigns the referee to another participant from the round. +/// +/// The type of the participant. +/// The type of the referee. +public class OtherFromRoundRefereeAssigner : IRefereeAssigner where TP : struct, IEquatable where TR : struct, IEquatable +{ + private readonly ParticipantCombinations _participantCombinations = new(); + private readonly IList _referees; + + public OtherFromRoundRefereeAssigner(IList? referees = null) + { + _referees = referees ?? throw new ArgumentNullException(nameof(referees)); + } + + /// + /// The referee assignment type. + /// + public const RefereeType AssignmentType = RefereeType.OtherFromRound; + + /// + public TR? GetReferee((int Turn, TP Home, TP Guest) match) + { + var lastMaxRefereeCount = int.MaxValue; + var lastMaxReferee = _referees[0]; + + foreach (var participant in _referees) + { + var currentRefereeCount = GetNumOfRefereeCombinations(participant); + if (currentRefereeCount >= lastMaxRefereeCount || + participant.Equals(match.Home) || participant.Equals(match.Guest) || + IsLastReferee(participant)) continue; + + lastMaxRefereeCount = currentRefereeCount; + lastMaxReferee = participant; + } + _participantCombinations.Add(new ParticipantCombination(0, match.Home, match.Guest, (TR?) (object?) lastMaxReferee)); + + return lastMaxReferee; + + } + + /// + /// Checks whether the was referee in the last match. + /// Always returns false if the match collection is empty. + /// + /// The referee to check whether it was referee in the last match. + /// Return true, if the referee was referee in the last match, false otherwise. + private bool IsLastReferee(TR referee) + { + return _participantCombinations.Count != 0 && referee.Equals(_participantCombinations[^1].Referee); + } + + /// + /// Calculates the number of matches a referee was assigned referee. + /// + /// The referee to calculate the number of referee matches. + /// The number of referee matches for the participant. + private int GetNumOfRefereeCombinations(TR referee) + { + return _participantCombinations.Count(match => match.Referee != null && match.Referee.Equals(referee)); + } +} diff --git a/TournamentManager/TournamentManager/Plan/ParticipantCombination.cs b/TournamentManager/TournamentManager/Plan/ParticipantCombination.cs new file mode 100644 index 00000000..c56a4284 --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/ParticipantCombination.cs @@ -0,0 +1,61 @@ +namespace TournamentManager.Plan; + +/// +/// Class for the combination of two participants and an optional referee for a match. +/// +/// The type of the participant objects. +/// The type of the referee objects +internal class ParticipantCombination where TP : struct where TR : struct +{ + /// + /// A participant combination of home/guest participants and an optional referee. + /// + /// The turn number for this combination. + /// The home participant object. + /// The guest participant object. + /// The referee object. + public ParticipantCombination(int turn, TP home, TP guest, TR? referee) + { + Turn = turn; + Home = home; + Guest = guest; + Referee = referee; + } + + /// + /// Gets or sets the turn of this combination. + /// + public int Turn { get; set; } + + /// + /// Gets or sets the home participant of this combination. + /// + public TP Home { get; set; } + + /// + /// Gets or sets the guest participant of this combination. + /// + public TP Guest { get; set; } + + /// + /// Gets or sets the referee of this combination. + /// + public TR? Referee { get; set; } + + /// + /// Swaps the home and guest participants. + /// + public void SwapHomeGuest() + { + (Home, Guest) = (Guest, Home); + } + + /// + /// Returns the string representation of the participants' combination. + /// + /// Returns the string representation of the participants' combination. + public override string ToString() + { + return string.Concat(Home.ToString(), " : ", Guest.ToString(), " / ", Referee?.ToString() ?? "-"); + } +} diff --git a/TournamentManager/TournamentManager/Plan/ParticipantCombinations.cs b/TournamentManager/TournamentManager/Plan/ParticipantCombinations.cs new file mode 100644 index 00000000..276ac1ec --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/ParticipantCombinations.cs @@ -0,0 +1,36 @@ +using System.Collections.ObjectModel; + +namespace TournamentManager.Plan; + +/// +/// The class for a collection of objects. +/// +/// The type of the participant objects. +/// The type of the referee objects +internal class ParticipantCombinations : Collection> where TP : struct where TR : struct +{ + /// + /// The s per turn. + /// This is used by to set the desired per turn. + /// + public Dictionary TurnDateTimePeriods { get; } = new(); + + /// + /// Gets the list of unique turns of the collection. + /// + /// The list of unique turns of the collection. + public IEnumerable GetTurns() + { + return this.Select(pc => pc.Turn).Distinct().OrderBy(pc => pc); + } + + /// + /// Gets the list of objects for a given turn. + /// + /// + /// The list of objects for a given turn. + public IEnumerable> GetCombinations(int turn) + { + return this.Where(pc => pc.Turn == turn); + } +} diff --git a/TournamentManager/TournamentManager/Plan/RefereeAssigners.cs b/TournamentManager/TournamentManager/Plan/RefereeAssigners.cs new file mode 100644 index 00000000..f564ed76 --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/RefereeAssigners.cs @@ -0,0 +1,26 @@ +namespace TournamentManager.Plan; + +/// +/// Keeps a list of referee assigners. +/// +/// The type of the participant. +/// The type of the referee. +internal static class RefereeAssigners where TP : struct, IEquatable where TR : struct, IEquatable +{ + /// + /// Returns the referee assigner for the referee assignment type. + /// + /// The . + /// The list of referees (optional). + /// The for the referee assignment type. + public static IRefereeAssigner GetRefereeAssigner(RefereeType refereeAssignmentType, IList? referees = null) + { + return refereeAssignmentType switch { + RefereeType.None => new NoRefereeAssigner(referees), + RefereeType.Home => new HomeRefereeAssigner(referees), + RefereeType.Guest => new GuestRefereeAssigner(referees), + RefereeType.OtherFromRound => new OtherFromRoundRefereeAssigner(referees), + _ => throw new ArgumentOutOfRangeException(nameof(refereeAssignmentType), refereeAssignmentType, null), + }; + } +} diff --git a/TournamentManager/TournamentManager/Plan/RefereeType.cs b/TournamentManager/TournamentManager/Plan/RefereeType.cs new file mode 100644 index 00000000..6423cef7 --- /dev/null +++ b/TournamentManager/TournamentManager/Plan/RefereeType.cs @@ -0,0 +1,24 @@ +namespace TournamentManager.Plan; + +/// +/// Specifies the referee type for a match. +/// +public enum RefereeType +{ + /// + /// By default, no referee is assigned. + /// + None = 0, + /// + /// The home participant is the referee. + /// + Home, + /// + /// The guest participant is the referee. + /// + Guest, + /// + /// The referee is another participant of the round. + /// + OtherFromRound +} diff --git a/TournamentManager/TournamentManager/Plan/RoundRobinSystem.cs b/TournamentManager/TournamentManager/Plan/RoundRobinSystem.cs deleted file mode 100644 index bf9d6e4e..00000000 --- a/TournamentManager/TournamentManager/Plan/RoundRobinSystem.cs +++ /dev/null @@ -1,335 +0,0 @@ -using System.Collections.ObjectModel; - -namespace TournamentManager.Plan; - -/// -/// Specifies which team will be assigned referee for a match. -/// -public enum RefereeType -{ - HomeTeam = 1, - GuestTeam, - OtherTeamOfGroup -} - -/// -/// Specifies the type of the leg to calculate match combinations. -/// -public enum LegType -{ - First = 1, - Return -} - -/// -/// This generic class calculates all matches for a group of teams. -/// The round robin system is applied, i.e. all teams in the group play each other. -/// -/// The type of the team objects. Objects must have IComparable implemented. -public class RoundRobinSystem -{ - private int _maxNumOfCombinations; - private readonly TeamCombinationGroup _combinationGroup = new(); - private readonly TeamCombinationGroup _combinationGroupReturnLeg = new(); - - /// - /// Constructor. - /// - /// A collection of teams to build matches for. - public RoundRobinSystem(Collection teams) - { - Teams = teams; - } - - /// - /// Doing the job of creating matches for all teams. - /// - private void CalcCombinations() - { - /* - Round robin match calculations are as follows. - Example with 5 teams (which total in 10 matches): - +- A -+ -+ -+ - | | | | - +- | +- B -+ -+ | | - | | | | | | - +- | | | C -+ -+ | -+ - | | | | | | - | | | +- D -+ -+ -+ - | | | | - +- +- +- E -+ - */ - if (Teams.Count < 2) - throw new InvalidOperationException("Round Robin system requires at least 2 teams."); - - if (Teams.Count < 3 && RefereeType == RefereeType.OtherTeamOfGroup) - throw new InvalidOperationException("Round Robin system with separate referee requires at least 3 teams."); - - _combinationGroup.Clear(); - _maxNumOfCombinations = Teams.Count * (Teams.Count - 1) / 2; - TeamCombinationsPerLeg = Teams.Count - 1; - - for (var count = 0; count < _maxNumOfCombinations; count++) - { - var homeTeam = GetHomeTeam(); - var guestTeam = GetGuestTeam(homeTeam); - var referee = homeTeam; - - var homeTeamNumOfHomeCombs = GetNumOfHomeCombinations(homeTeam); - var guestTeamNumOfHomeCombs = GetNumOfHomeCombinations(guestTeam); - - // make sure that home matches are alternating - if (homeTeamNumOfHomeCombs <= guestTeamNumOfHomeCombs) - _combinationGroup.Add(new TeamCombination(homeTeam, guestTeam, referee)); - else - _combinationGroup.Add(new TeamCombination(guestTeam, homeTeam, referee)); - - // re-assign referee according to settings - switch (RefereeType) - { - case RefereeType.HomeTeam: - _combinationGroup[count].Referee = _combinationGroup[count].HomeTeam; break; - case RefereeType.GuestTeam: - _combinationGroup[count].Referee = _combinationGroup[count].GuestTeam; break; - case RefereeType.OtherTeamOfGroup: - _combinationGroup[count].Referee = GetReferee(homeTeam, guestTeam); break; - } - } - - CalcCombinationsReturnLeg(); - } - - /// - /// Calculates the return leg based on the previously calculated first leg - /// by swapping home / guest team and assigning the referee. - /// - private void CalcCombinationsReturnLeg() - { - _combinationGroupReturnLeg.Clear(); - var referee = _combinationGroup[0].Referee; // just to make the compiler happy - - foreach (var match in _combinationGroup) - { - // re-assign referee according to settings: - switch (RefereeType) - { - case RefereeType.HomeTeam: - referee = match.GuestTeam; break; - case RefereeType.GuestTeam: - referee = match.HomeTeam; break; - case RefereeType.OtherTeamOfGroup: - referee = match.Referee; break; - } - _combinationGroupReturnLeg.Add(new TeamCombination(match.GuestTeam, match.HomeTeam, referee)); - } - } - - - /// - /// Determines the next home team by checking which of the - /// teams has least matches up to now. - /// - /// Returns the home team. - private T GetHomeTeam() - { - var lastMaxMissingCount = int.MinValue; - var lastMaxMissingTeam = Teams[0]; - - foreach (var team in Teams) - { - var currentMissingCount = GetMissingCombinationsCount(team); - if (currentMissingCount > lastMaxMissingCount) - { - lastMaxMissingCount = currentMissingCount; - lastMaxMissingTeam = team; - } - } - return lastMaxMissingTeam; - } - - /// - /// Determines the next guest team by checking - /// a) the team with least matches, that is not identical to home team - /// b) that the team combination for the two teams does not exists (home/guest, guest/home). - /// - /// The home team already fixed for the match. - /// Returns the guest team. - private T GetGuestTeam(T homeTeam) - { - var lastMaxMissingCount = int.MinValue; - var lastMaxMissingTeam = Teams[0]; - - foreach (var team in Teams) - { - var currentMissingCount = GetMissingCombinationsCount(team); - if (currentMissingCount > lastMaxMissingCount && (Comparer.Default.Compare(team, homeTeam) != 0) && !CombinationExists(homeTeam, team)) - { - lastMaxMissingCount = currentMissingCount; - lastMaxMissingTeam = team; - } - } - - return lastMaxMissingTeam; - } - - - /// - /// Determines the referee for a match. - /// - /// The home team of the match. - /// The guest team of the match. - /// - private T GetReferee(T homeTeam, T guestTeam) - { - var lastMaxRefereeCount = int.MaxValue; - var lastMaxRefereeTeam = Teams[0]; - - foreach (var team in Teams) - { - var currentRefereeCount = GetNumOfRefereeCombinations(team); - if (currentRefereeCount < lastMaxRefereeCount && - (Comparer.Default.Compare(team, homeTeam) != 0) && (Comparer.Default.Compare(team, guestTeam) != 0) && - ! IsLastReferee(team)) - { - lastMaxRefereeCount = currentRefereeCount; - lastMaxRefereeTeam = team; - } - } - - return lastMaxRefereeTeam; - } - - - /// - /// Checks whether the team was referee in the last match. - /// Always returns false if the match collection is empty. - /// - /// The team to check whether it was referee in the last match. - /// Return true, if the team was referee in the last match, false otherwise. - private bool IsLastReferee(T team) - { - if (_combinationGroup.Count == 0) - return false; - else - return (Comparer.Default.Compare(team, _combinationGroup[^1].Referee) == 0); - } - - - /// - /// Calculates how many matches for the team are still not fixed. - /// - /// The team to calculate missing matches for. - /// Returns the number of missing matches for the team. - private int GetMissingCombinationsCount(T team) - { - var missing = TeamCombinationsPerLeg; - - foreach (var match in _combinationGroup) - { - if (Comparer.Default.Compare(match.HomeTeam, team) == 0) missing--; - if (Comparer.Default.Compare(match.GuestTeam, team) == 0) missing--; - } - - return missing; - } - - /// - /// Checks whether the team combination for the two teams - /// does not exists (either home/guest, or guest/home). - /// - /// The home team to compare. - /// The guest team to compare. - /// Returns true, if the team combination already exists, false otherwise. - private bool CombinationExists(T homeTeam, T guestTeam) - { - foreach (var match in _combinationGroup) - { - if ((Comparer.Default.Compare(match.HomeTeam, homeTeam) == 0) && (Comparer.Default.Compare(match.GuestTeam, guestTeam) == 0)) - return true; - } - return false; - } - - /// - /// Calculates the number of team matches already fixed for the team. - /// - /// The team to calculate the number of home matches. - /// The number of home matches for the team. - private int GetNumOfHomeCombinations(T team) - { - var count = 0; - - foreach (var match in _combinationGroup) - { - if (Comparer.Default.Compare(match.HomeTeam, team) == 0) count++; - } - return count; - } - - /// - /// Calculates the number of matches a team was assigned referee. - /// - /// The team to calculate the number of referee matches. - /// The number of referee matches for the team. - private int GetNumOfRefereeCombinations(T team) - { - var count = 0; - - foreach (var match in _combinationGroup) - { - if (Comparer.Default.Compare(match.Referee, team) == 0) count++; - } - return count; - } - - - /// - /// Calculates all matches for the given teams, using the round robin system. - /// - /// Determines how referees will be assigned for the matches. - /// First leg or return leg. - /// Optimization type for groups. Differences can be seen be with an uneven number of teams. - /// Return a collection containing collections of optimized team combinations. - public Collection> GetBundledGroups(RefereeType refereeType, LegType legType, CombinationGroupOptimization optiType) - { - RefereeType = refereeType; - CalcCombinations(); - - return (new CombinationGroupOptimizer((legType == LegType.First) ? _combinationGroup : _combinationGroupReturnLeg).GetBundledGroups(optiType)); - } - - /// - /// Calculates all matches for the given teams, using the round robin system. - /// - /// Determines how referees will be assigned for the matches. - /// First leg or return leg. - /// Return a collection of team combinations. - public TeamCombinationGroup GetCombinationGroup(RefereeType refereeType, LegType legType) - { - RefereeType = refereeType; - CalcCombinations(); - - return (legType == LegType.First) ? _combinationGroup : _combinationGroupReturnLeg; - } - - - /// - /// Gets the number of matches per team. - /// - public int TeamCombinationsPerLeg { get; private set; } - - /// - /// Gets the number of teams. - /// - public int NumberOfTeams => Teams.Count; - - /// - /// Gets the teams. - /// - public Collection Teams { get; } = new(); - - /// - /// Gets whether the home team is also assigned referee (true for 'yes'). - /// - public RefereeType RefereeType { get; private set; } = RefereeType.HomeTeam; -} diff --git a/TournamentManager/TournamentManager/Plan/TeamCombination.cs b/TournamentManager/TournamentManager/Plan/TeamCombination.cs deleted file mode 100644 index 2e6eec58..00000000 --- a/TournamentManager/TournamentManager/Plan/TeamCombination.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace TournamentManager.Plan; - -/// -/// Generic class to store the team pairs for a match. -/// -/// The type of the team objects. -public class TeamCombination -{ - /// - /// Creates a new team combination of home/guest team and a referee. - /// - /// The home team object. - /// The guest team object. - /// - public TeamCombination(T homeTeam, T guestTeam, T referee) - { - HomeTeam = homeTeam; - GuestTeam = guestTeam; - Referee = referee; - } - - /// - /// Gets or sets the home team of this combination. - /// - public T HomeTeam { get; set; } - - /// - /// Gets or sets the guest team of this combination. - /// - public T GuestTeam { get; set; } - - /// - /// Gets or sets the referee of this combination. - /// - public T Referee { get; set; } - - /// - /// Returns the string representation of the team combination. - /// - /// Returns the string representation of the team combination. - public override string ToString() - { - return string.Concat(HomeTeam?.ToString() ?? nameof(HomeTeam), " : ", GuestTeam?.ToString() ?? nameof(GuestTeam), " / ", Referee?.ToString() ?? nameof(Referee)); - } -} \ No newline at end of file diff --git a/TournamentManager/TournamentManager/Plan/TeamCombinationGroup.cs b/TournamentManager/TournamentManager/Plan/TeamCombinationGroup.cs deleted file mode 100644 index 20833217..00000000 --- a/TournamentManager/TournamentManager/Plan/TeamCombinationGroup.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Collections.ObjectModel; - -namespace TournamentManager.Plan; - -public class TeamCombinationGroup : Collection> -{ - internal DateTimePeriod DateTimePeriod = new(); -} \ No newline at end of file diff --git a/TournamentManager/TournamentManager/RoundRobin/IRoundRobinSystem.cs b/TournamentManager/TournamentManager/RoundRobin/IRoundRobinSystem.cs new file mode 100644 index 00000000..19747d42 --- /dev/null +++ b/TournamentManager/TournamentManager/RoundRobin/IRoundRobinSystem.cs @@ -0,0 +1,15 @@ +namespace TournamentManager.RoundRobin; + +internal interface IRoundRobinSystem where TP : struct, IEquatable +{ + /// + /// Gets the list of participants. + /// + ICollection Participants { get; } + + /// + /// Generates a list of matches using the round-robin system with for the given participants. + /// + /// A list of matches represented as s of turn and home / guest participants. + IList<(int Turn, TP Home, TP Guest)> GenerateMatches(); +} diff --git a/TournamentManager/TournamentManager/RoundRobin/IdealRoundRobin.cs b/TournamentManager/TournamentManager/RoundRobin/IdealRoundRobin.cs new file mode 100644 index 00000000..52125b52 --- /dev/null +++ b/TournamentManager/TournamentManager/RoundRobin/IdealRoundRobin.cs @@ -0,0 +1,790 @@ +namespace TournamentManager.RoundRobin; + +// Inspired by Pit Schneider's Ideal Round Robin +// on +/// +/// This generic class calculates all matches for a group of participants. +/// The round-robin system is applied, i.e. all participants in the group play each other. +/// Then the number of home/guest matches is optimized manually. +/// That's why only 5 to 14 participants are supported. +/// +/// The participant type. +internal class IdealRoundRobinSystem : IRoundRobinSystem where TP : struct, IEquatable +{ + #region *** Description of Ideal Round Robin *** + + /* + Ideal Round Robin + (Text published by Pit Schneider on 2021-06-21 on https://github.com/Schneipi/ideal-round-robin) + + Licensed under GNU General Public License v3.0 + + Given n teams, every round robin schedule has the following properties: + + A) n is even + + 1. Mirrored double round robin schedule: Same games in round k and round k+n-1, + except inverted home advantage. + 2. Total number of breaks (consecutive home/away games) is a minimum and + equals 3n-6 [de Werra 1981]. + 3. First n-1 rounds on their own compose a single round robin schedule with + a minimum of n-2 breaks [de Werra 1981] (odd team numbers have n/2 home games). + 4. No breaks in rounds 2 and n-1, entailing that every team has precisely 1 home + game in the first 2 rounds and precisely 1 home game in the last 2 rounds. + 5. No consecutive breaks, entailing no more than 2 home/away games in a row for every team. + 6. Teams 1 and 2 have 0 breaks, all other teams have 3 breaks. + 7. The higher the team number, the lower the round number containing + the first break for the team. + + b) n is odd + + 1. Mirrored double round robin schedule: Same games in round k and round k+n, + except inverted home advantage. + 2. Total number of breaks (consecutive home/away games without considering bye rounds) + is a minimum and equals n [de Werra 1981]. + 3. First n rounds on their own compose a single round robin schedule with a + minimum of 0 breaks [de Werra 1981]. + 4. An optimum amount of n-1 teams have precisely 1 home game in the first 2 rounds + and precisely 1 home game in the last 2 rounds. + 5. No consecutive breaks, entailing no more than 2 home/away games in a row for every team. + 6. Every team has exactly 1 break. + 7. The higher the team number, the lower the round number containing the first break for the team. + */ + + #endregion + + /// + /// Initializes a new instance of the class. + /// + /// The collection of participants. + public IdealRoundRobinSystem(ICollection participants) + { + Participants = participants; + } + + /// + public ICollection Participants { get; } + + /* + round01 04-03 05-02 + round02 01-04 03-05 + round03 02-03 05-01 + round04 01-02 04-05 + round05 02-04 03-01 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants5 = + new() + { + (1, 4, 3), + (1, 5, 2), + + (2, 1, 4), + (2, 3, 5), + + (3, 2, 3), + (3, 5, 1), + + (4, 1, 2), + (4, 4, 5), + + (5, 2, 4), + (5, 3, 1) + }; + + /* + round01 01-05 03-02 06-04 + round02 02-06 04-01 05-03 + round03 01-02 03-06 05-04 + round04 02-04 03-01 06-05 + round05 01-06 04-03 05-02 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants6 = + new() + { + (1, 1, 5), + (1, 3, 2), + (1, 6, 4), + + (2, 2, 6), + (2, 4, 1), + (2, 5, 3), + + (3, 1, 2), + (3, 3, 6), + (3, 5, 4), + + (4, 2, 4), + (4, 3, 1), + (4, 6, 5), + + (5, 1, 6), + (5, 4, 3), + (5, 5, 2) + }; + + /* + round01 05-04 06-03 07-02 + round02 01-06 03-05 04-07 + round03 02-04 05-01 07-03 + round04 01-07 03-02 06-05 + round05 02-01 04-03 07-06 + round06 01-04 05-07 06-02 + round07 02-05 03-01 04-06 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants7 = + new() + { + (1, 5, 4), + (1, 6, 3), + (1, 7, 2), + + (2, 1, 6), + (2, 3, 5), + (2, 4, 7), + + (3, 2, 4), + (3, 5, 1), + (3, 7, 3), + + (4, 1, 7), + (4, 3, 2), + (4, 6, 5), + + (5, 2, 1), + (5, 4, 3), + (5, 7, 6), + + (6, 1, 4), + (6, 5, 7), + (6, 6, 2), + + (7, 2, 5), + (7, 3, 1), + (7, 4, 6) + }; + + /* + round01 01-07 03-06 05-04 08-02 + round02 02-05 04-03 06-01 07-08 + round03 01-04 03-02 05-08 07-06 + round04 02-01 04-07 05-03 08-06 + round05 01-05 03-08 06-04 07-02 + round06 02-06 03-01 05-07 08-04 + round07 01-08 04-02 06-05 07-03 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants8 = + new() + { + (1, 1, 7), + (1, 3, 6), + (1, 5, 4), + (1, 8, 2), + + (2, 2, 5), + (2, 4, 3), + (2, 6, 1), + (2, 7, 8), + + (3, 1, 4), + (3, 3, 2), + (3, 5, 8), + (3, 7, 6), + + (4, 2, 1), + (4, 4, 7), + (4, 5, 3), + (4, 8, 6), + + (5, 1, 5), + (5, 3, 8), + (5, 6, 4), + (5, 7, 2), + + (6, 2, 6), + (6, 3, 1), + (6, 5, 7), + (6, 8, 4), + + (7, 1, 8), + (7, 4, 2), + (7, 6, 5), + (7, 7, 3) + }; + + /* + round01 06-05 07-04 08-03 09-02 + round02 01-08 03-06 04-09 05-07 + round03 02-04 06-01 07-03 09-05 + round04 01-07 03-09 05-02 08-06 + round05 02-03 04-05 07-08 09-01 + round06 01-02 03-04 06-07 08-09 + round07 02-08 04-01 05-03 09-06 + round08 01-05 06-02 07-09 08-04 + round09 02-07 03-01 04-06 05-08 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants9 = + new() + { + (1, 6, 5), + (1, 7, 4), + (1, 8, 3), + (1, 9, 2), + + (2, 1, 8), + (2, 3, 6), + (2, 4, 9), + (2, 5, 7), + + (3, 2, 4), + (3, 6, 1), + (3, 7, 3), + (3, 9, 5), + + (4, 1, 7), + (4, 3, 9), + (4, 5, 2), + (4, 8, 6), + + (5, 2, 3), + (5, 4, 5), + (5, 7, 8), + (5, 9, 1), + + (6, 1, 2), + (6, 3, 4), + (6, 6, 7), + (6, 8, 9), + + (7, 2, 8), + (7, 4, 1), + (7, 5, 3), + (7, 9, 6), + + (8, 1, 5), + (8, 6, 2), + (8, 7, 9), + (8, 8, 4), + + (9, 2, 7), + (9, 3, 1), + (9, 4, 6), + (9, 5, 8) + }; + + /* + round01 01-09 03-07 06-02 08-05 10-04 + round02 02-10 04-08 05-03 07-01 09-06 + round03 01-05 03-04 06-10 08-02 09-07 + round04 02-03 04-01 05-09 07-06 10-08 + round05 01-02 03-10 06-08 07-05 09-04 + round06 02-09 04-07 05-06 08-03 10-01 + round07 01-08 03-06 05-04 07-02 09-10 + round08 02-05 03-01 06-04 08-09 10-07 + round09 01-06 04-02 05-10 07-08 09-03 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants10 = + new() + { + (1, 1, 9), + (1, 3, 7), + (1, 6, 2), + (1, 8, 5), + (1, 10, 4), + + (2, 2, 10), + (2, 4, 8), + (2, 5, 3), + (2, 7, 1), + (2, 9, 6), + + (3, 1, 5), + (3, 3, 4), + (3, 6, 10), + (3, 8, 2), + (3, 9, 7), + + (4, 2, 3), + (4, 4, 1), + (4, 5, 9), + (4, 7, 6), + (4, 10, 8), + + (5, 1, 2), + (5, 3, 10), + (5, 6, 8), + (5, 7, 5), + (5, 9, 4), + + (6, 2, 9), + (6, 4, 7), + (6, 5, 6), + (6, 8, 3), + (6, 10, 1), + + (7, 1, 8), + (7, 3, 6), + (7, 5, 4), + (7, 7, 2), + (7, 9, 10), + + (8, 2, 5), + (8, 3, 1), + (8, 6, 4), + (8, 8, 9), + (8, 10, 7), + + (9, 1, 6), + (9, 4, 2), + (9, 5, 10), + (9, 7, 8), + (9, 9, 3), + }; + + /* + round01 07-06 08-05 09-04 10-03 11-02 + round02 01-10 03-08 04-11 05-07 06-09 + round03 02-04 07-03 08-01 09-05 11-06 + round04 01-07 03-09 05-11 06-02 10-08 + round05 02-05 04-06 07-10 09-01 11-03 + round06 01-11 03-02 05-04 08-07 10-09 + round07 02-01 04-03 06-05 09-08 11-10 + round08 01-04 03-06 07-09 08-11 10-02 + round09 02-08 04-10 05-03 06-01 11-07 + round10 01-05 07-02 08-04 09-11 10-06 + round11 02-09 03-01 04-07 05-10 06-08 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants11 = + new() + { + (1, 7, 6), + (1, 8, 5), + (1, 9, 4), + (1, 10, 3), + (1, 11, 2), + + (2, 1, 10), + (2, 3, 8), + (2, 4, 11), + (2, 5, 7), + (2, 6, 9), + + (3, 2, 4), + (3, 7, 3), + (3, 8, 1), + (3, 9, 5), + (3, 11, 6), + + (4, 1, 7), + (4, 3, 9), + (4, 5, 11), + (4, 6, 2), + (4, 10, 8), + + (5, 2, 5), + (5, 4, 6), + (5, 7, 10), + (5, 9, 1), + (5, 11, 3), + + (6, 1, 11), + (6, 3, 2), + (6, 5, 4), + (6, 8, 7), + (6, 10, 9), + + (7, 2, 1), + (7, 4, 3), + (7, 6, 5), + (7, 9, 8), + (7, 11, 10), + + (8, 1, 4), + (8, 3, 6), + (8, 7, 9), + (8, 8, 11), + (8, 10, 2), + + (9, 2, 8), + (9, 4, 10), + (9, 5, 3), + (9, 6, 1), + (9, 11, 7), + + (10, 1, 5), + (10, 7, 2), + (10, 8, 4), + (10, 9, 11), + (10, 10, 6), + + (11, 2, 9), + (11, 3, 1), + (11, 4, 7), + (11, 5, 10), + (11, 6, 8) + }; + + /* + round01 01-07 03-11 05-09 08-06 10-04 12-02 + round02 02-10 04-08 06-05 07-12 09-03 11-01 + round03 01-09 03-06 05-04 08-02 10-12 11-07 + round04 02-05 04-03 06-01 07-10 09-11 12-08 + round05 01-04 03-02 05-12 08-10 09-07 11-06 + round06 02-01 04-11 06-09 07-08 10-05 12-03 + round07 01-12 03-10 05-08 07-06 09-04 11-02 + round08 02-09 04-06 05-07 08-03 10-01 12-11 + round09 01-08 03-05 06-02 07-04 09-12 11-10 + round10 02-04 03-07 05-01 08-11 10-09 12-06 + round11 01-03 04-12 06-10 07-02 09-08 11-05 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants12 = + new() + { + (1, 1, 7), + (1, 3, 11), + (1, 5, 9), + (1, 8, 6), + (1, 10, 4), + (1, 12, 2), + + (2, 2, 10), + (2, 4, 8), + (2, 6, 5), + (2, 7, 12), + (2, 9, 3), + (2, 11, 1), + + (3, 1, 9), + (3, 3, 6), + (3, 5, 4), + (3, 8, 2), + (3, 10, 12), + (3, 11, 7), + + (4, 2, 5), + (4, 4, 3), + (4, 6, 1), + (4, 7, 10), + (4, 9, 11), + (4, 12, 8), + + (5, 1, 4), + (5, 3, 2), + (5, 5, 12), + (5, 8, 10), + (5, 9, 7), + (5, 11, 6), + + (6, 2, 1), + (6, 4, 11), + (6, 6, 9), + (6, 7, 8), + (6, 10, 5), + (6, 12, 3), + + (7, 1, 12), + (7, 3, 10), + (7, 5, 8), + (7, 7, 6), + (7, 9, 4), + (7, 11, 2), + + (8, 2, 9), + (8, 4, 6), + (8, 5, 7), + (8, 8, 3), + (8, 10, 1), + (8, 12, 11), + + (9, 1, 8), + (9, 3, 5), + (9, 6, 2), + (9, 7, 4), + (9, 9, 12), + (9, 11, 10), + + (10, 2, 4), + (10, 3, 7), + (10, 5, 1), + (10, 8, 11), + (10, 10, 9), + (10, 12, 6), + + (11, 1, 3), + (11, 4, 12), + (11, 6, 10), + (11, 7, 2), + (11, 9, 8), + (11, 11, 5) + }; + + /* + round01 08-07 09-06 10-05 11-04 12-03 13-02 + round02 01-12 03-10 04-13 05-08 06-11 07-09 + round03 02-04 08-03 09-05 10-01 11-07 13-06 + round04 01-08 03-09 05-11 06-02 07-13 12-10 + round05 02-07 04-06 08-12 09-01 11-03 13-05 + round06 01-11 03-13 05-02 07-04 10-08 12-09 + round07 02-03 04-05 06-07 09-10 11-12 13-01 + round08 01-02 03-04 05-06 08-09 10-11 12-13 + round09 02-12 04-01 06-03 07-05 11-08 13-10 + round10 01-06 03-07 08-13 09-11 10-02 12-04 + round11 02-08 04-10 05-03 06-12 07-01 13-09 + round12 01-05 08-04 09-02 10-06 11-13 12-07 + round13 02-11 03-01 04-09 05-12 06-08 07-10 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants13 = + new() + { + (1, 8, 7), + (1, 9, 6), + (1, 10, 5), + (1, 11, 4), + (1, 12, 3), + (1, 13, 2), + + (2, 1, 12), + (2, 3, 10), + (2, 4, 13), + (2, 5, 8), + (2, 6, 11), + (2, 7, 9), + + (3, 2, 4), + (3, 8, 3), + (3, 9, 5), + (3, 10, 1), + (3, 11, 7), + (3, 13, 6), + + (4, 1, 8), + (4, 3, 9), + (4, 5, 11), + (4, 6, 2), + (4, 7, 13), + (4, 12, 10), + + (5, 2, 7), + (5, 4, 6), + (5, 8, 12), + (5, 9, 1), + (5, 11, 3), + (5, 13, 5), + + (6, 1, 11), + (6, 3, 13), + (6, 5, 2), + (6, 7, 4), + (6, 10, 8), + (6, 12, 9), + + (7, 2, 3), + (7, 4, 5), + (7, 6, 7), + (7, 9, 10), + (7, 11, 12), + (7, 13, 1), + + (8, 1, 2), + (8, 3, 4), + (8, 5, 6), + (8, 8, 9), + (8, 10, 11), + (8, 12, 13), + + (9, 2, 12), + (9, 4, 1), + (9, 6, 3), + (9, 7, 5), + (9, 11, 8), + (9, 13, 10), + + (10, 1, 6), + (10, 3, 7), + (10, 8, 13), + (10, 9, 11), + (10, 10, 2), + (10, 12, 4), + + (11, 2, 8), + (11, 4, 10), + (11, 5, 3), + (11, 6, 12), + (11, 7, 1), + (11, 13, 9), + + (12, 1, 5), + (12, 8, 4), + (12, 9, 2), + (12, 10, 6), + (12, 11, 13), + (12, 12, 7), + + (13, 2, 11), + (13, 3, 1), + (13, 4, 9), + (13, 5, 12), + (13, 6, 8), + (13, 7, 10) + }; + + /* + round01 01-14 04-09 06-07 08-05 10-03 12-02 13-11 + round02 02-10 03-08 05-06 07-04 09-13 11-01 14-12 + round03 01-09 04-05 06-03 08-02 10-12 11-14 13-07 + round04 02-06 03-04 05-13 07-01 09-11 12-08 14-10 + round05 01-05 04-02 06-12 08-10 09-14 11-07 13-03 + round06 02-13 03-01 05-11 07-09 10-06 12-04 14-08 + round07 01-02 04-10 06-08 07-14 09-05 11-03 13-12 + round08 02-11 03-09 05-07 08-04 10-13 12-01 14-06 + round09 01-10 04-06 05-14 07-03 09-02 11-12 13-08 + round10 02-07 03-05 06-13 08-01 10-11 12-09 14-04 + round11 01-06 03-14 05-02 07-12 09-10 11-08 13-04 + round12 02-03 04-01 06-11 08-09 10-07 12-05 13-14 + round13 01-13 03-12 05-10 07-08 09-06 11-04 14-02 + */ + private readonly List<(int Turn, int Home, int Guest)> _numOfParticipants14 = + new() + { + (1, 1, 14), + (1, 4, 9), + (1, 6, 7), + (1, 8, 5), + (1, 10, 3), + (1, 12, 2), + (1, 13, 11), + + (2, 2, 10), + (2, 3, 8), + (2, 5, 6), + (2, 7, 4), + (2, 9, 13), + (2, 11, 1), + (2, 14, 12), + + (3, 1, 9), + (3, 4, 5), + (3, 6, 3), + (3, 8, 2), + (3, 10, 12), + (3, 11, 14), + (3, 13, 7), + + (4, 2, 6), + (4, 3, 4), + (4, 5, 13), + (4, 7, 1), + (4, 9, 11), + (4, 12, 8), + (4, 14, 10), + + (5, 1, 5), + (5, 4, 2), + (5, 6, 12), + (5, 8, 10), + (5, 9, 14), + (5, 11, 7), + (5, 13, 3), + + (6, 2, 13), + (6, 3, 1), + (6, 5, 11), + (6, 7, 9), + (6, 10, 6), + (6, 12, 4), + (6, 14, 8), + + (7, 1, 2), + (7, 4, 10), + (7, 6, 8), + (7, 7, 14), + (7, 9, 5), + (7, 11, 3), + (7, 13, 12), + + (8, 2, 11), + (8, 3, 9), + (8, 5, 7), + (8, 8, 4), + (8, 10, 13), + (8, 12, 1), + (8, 14, 6), + + (9, 1, 10), + (9, 4, 6), + (9, 5, 14), + (9, 7, 3), + (9, 9, 2), + (9, 11, 12), + (9, 13, 8), + + (10, 2, 7), + (10, 3, 5), + (10, 6, 13), + (10, 8, 1), + (10, 10, 11), + (10, 12, 9), + (10, 14, 4), + + (11, 1, 6), + (11, 3, 14), + (11, 5, 2), + (11, 7, 12), + (11, 9, 10), + (11, 11, 8), + (11, 13, 4), + + (12, 2, 3), + (12, 4, 1), + (12, 6, 11), + (12, 8, 9), + (12, 10, 7), + (12, 12, 5), + (12, 13, 14), + + (13, 1, 13), + (13, 3, 12), + (13, 5, 10), + (13, 7, 8), + (13, 9, 6), + (13, 11, 4), + (13, 14, 2) + }; + + /// + /// Generates a list of ideal matches using the predefined combinations. + /// + /// A list of matches represented as s of turn and participants. + public IList<(int Turn, TP Home, TP Guest)> GenerateMatches() + { + return Participants.Count switch { + 5 => ToGenericList(_numOfParticipants5), + 6 => ToGenericList(_numOfParticipants6), + 7 => ToGenericList(_numOfParticipants7), + 8 => ToGenericList(_numOfParticipants8), + 9 => ToGenericList(_numOfParticipants9), + 10 => ToGenericList(_numOfParticipants10), + 11 => ToGenericList(_numOfParticipants11), + 12 => ToGenericList(_numOfParticipants12), + 13 => ToGenericList(_numOfParticipants13), + 14 => ToGenericList(_numOfParticipants14), + _ => throw new ArgumentOutOfRangeException(nameof(Participants), + @"The number of participants must be between 5 and 14.") + }; + } + + private List<(int Turn, TP Home, TP Guest)> ToGenericList(List<(int Turn, int Home, int Guest)> idealMatches) + { + var participants = new List(Participants); + + var genericMatches = new List<(int Turn, TP Home, TP Guest)>(); + foreach (var idealMatch in idealMatches) + { + // Make the result zero-based. + var (turn, home, guest) = (idealMatch.Turn, participants[idealMatch.Home - 1], participants[idealMatch.Guest - 1]); + genericMatches.Add((turn, home, guest)); + } + + return genericMatches; + } +} + diff --git a/TournamentManager/TournamentManager/RoundRobin/MatchesAnalyzer.cs b/TournamentManager/TournamentManager/RoundRobin/MatchesAnalyzer.cs new file mode 100644 index 00000000..357cbac1 --- /dev/null +++ b/TournamentManager/TournamentManager/RoundRobin/MatchesAnalyzer.cs @@ -0,0 +1,108 @@ +namespace TournamentManager.RoundRobin; + +internal class MatchesAnalyzer where TP : struct, IEquatable +{ + /// + /// Gets the total number of home/guest matches for each participant. + /// + /// + /// The total number of home/guest matches for each participant. + internal static Dictionary GetHomeGuestCount(IList<(int Turn, TP Home, TP Guest)> matches) + { + var result = new Dictionary(); + + foreach (var match in matches) + { + result.TryAdd(match.Home, (0, 0)); + result.TryAdd(match.Guest, (0, 0)); + + result[match.Home] = (result[match.Home].HomeCount + 1, result[match.Home].GuestCount); + result[match.Guest] = (result[match.Guest].HomeCount, result[match.Guest].GuestCount + 1); + } + + return result; + } + + /// + /// Gets the participants with their number of unbalanced home/guest matches. + /// For odd number of participants, home/guest matches must be the same. + /// For even number of participants, the home/guest difference may be 1 + /// (e.g. for 6 participants: 2 home, 3 guest matches) + /// + /// + /// The participants with their number of unbalanced home/guest matches. + public static Dictionary GetUnbalancedHomeGuestCount( + IList<(int Turn, TP Home, TP Guest)> matches) + { + // Get the total number of home/guest matches for all participants + var homeGuestCount = GetHomeGuestCount(matches); + var unbalancedHomeGuestCount = new Dictionary(); + + foreach (var hgc in homeGuestCount) + { + var minCount = (int) Math.Floor((homeGuestCount.Keys.Count - 1) / 2.0); + var maxCount = (int) Math.Ceiling((homeGuestCount.Keys.Count - 1) / 2.0 + .1); + if (hgc.Value.HomeCount < minCount || hgc.Value.GuestCount > maxCount || hgc.Value.GuestCount < minCount || hgc.Value.HomeCount > maxCount) + { + unbalancedHomeGuestCount.Add(hgc.Key, hgc.Value); + } + } + + return unbalancedHomeGuestCount; + } + + /// + /// Gets the maximum number of consecutive home/guest matches for the given participant. + /// + /// + /// + /// The maximum number of consecutive home/guest matches for the given participant. + public static (int HomeCount, int GuestCount) GetMaxConsecutiveHomeGuestCount(TP participant, IList<(int Turn, TP Home, TP Guest)> matches) + { + var homeCount = GetLastConsecutiveCounts(participant, true, matches).Max(); + var guestCount = GetLastConsecutiveCounts(participant, false, matches).Max(); + return (homeCount, guestCount); + } + + /// + /// Gets the number of most recent consecutive home/guest matches for the given participant. + /// + /// + /// + /// + /// The list of most recent consecutive home/guest matches for the given participant. + public static IEnumerable GetLastConsecutiveCounts(TP participant, bool forHome, IList<(int Turn, TP Home, TP Guest)> matches) + { + using var e = matches.Reverse().GetEnumerator(); + for (var more = e.MoveNext(); more;) + { + var first = forHome ? e.Current.Home : e.Current.Guest; + if (first.Equals(participant)) + { + var count = 1; + while (more && e.MoveNext()) + { + first = forHome ? e.Current.Home : e.Current.Guest; + var second = forHome ? e.Current.Guest : e.Current.Home; + + if (first.Equals(participant)) + { + count++; + } + + if (second.Equals(participant)) + { + break; + } + } + yield return count; + } + else + { + yield return 0; + } + more = e.MoveNext(); + } + } +} + diff --git a/TournamentManager/TournamentManager/RoundRobin/RoundRobinSystem.cs b/TournamentManager/TournamentManager/RoundRobin/RoundRobinSystem.cs new file mode 100644 index 00000000..7fe5f008 --- /dev/null +++ b/TournamentManager/TournamentManager/RoundRobin/RoundRobinSystem.cs @@ -0,0 +1,140 @@ +namespace TournamentManager.RoundRobin; + +// Inspired by Nikolay Kostov's suggestion +// on https://stackoverflow.com/questions/1293058/round-robin-tournament-algorithm-in-c-sharp +/// +/// This generic class calculates all matches for a group of participants. +/// The round robin system is applied, i.e. all participants in the group play each other. +/// Then the number of home/guest matches is analyzed and optimized. +/// +/// The participant type. +internal class RoundRobinSystem : IRoundRobinSystem where TP : struct, IEquatable +{ + /// + /// Initializes a new instance of the class. + /// + /// The collection of participants. + public RoundRobinSystem(ICollection participants) + { + Participants = participants; + } + + /// + public ICollection Participants { get; } + + + /// + /// Generates a list of matches using the round-robin system with any number of participants. + /// + /// A list of matches represented as s of turn and participants. + public IList<(int Turn, TP Home, TP Guest)> GenerateMatches() + { + /* + Round robin match calculations are as follows. + Example with 5 participants (which total in 10 matches): + +- A -+ -+ -+ + | | | | + +- | +- B -+ -+ | | + | | | | | | + +- | | | C -+ -+ | -+ + | | | | | | + | | | +- D -+ -+ -+ + | | | | + +- +- +- E -+ + */ + + // Create a new list of participants that we can modify. + var participants = new List(); + Participants.ToList().ForEach(p => participants.Add(p)); + + // If the number of participants is odd, add a default value to make it even. + if (participants.Count % 2 != 0) + { + participants.Add(default); + } + + // Calculate the number of turns required to ensure that + // each participant plays every other participant exactly once. + var numTurns = participants.Count - 1; + + // Calculate the number of participants required for each turn. + var halfSize = participants.Count / 2; + + // Create a working list of participants for each turn. + // The first participant is always the first participant and is not included in the list. + // The other participants are the remaining participants, split into two halves. + var pList = new List(); + // Add the list except the first participant. + pList.AddRange(participants.Skip(1)); + + var pListSize = pList.Count; + + // Create a list of matches. + var matches = new List<(int Turn, TP Home, TP Guest)>(); + + // Generate the matches for each turn. + for (var turn = 0; turn < numTurns; turn++) + { + // Add the match between the first participant and the participant for the turn. + var secondParticipant = pList[turn % pListSize]; + if (!secondParticipant.Equals(default)) + { + // Alternate home/guest matches. + matches.Add(turn % 2 == 0 + ? (turn + 1, (TP) participants[0]!, (TP) secondParticipant) + : (turn + 1, (TP) secondParticipant, (TP) participants[0]!)); + } + + // Add the matches between the other participants. + for (var idx = 1; idx < halfSize; idx++) + { + var firstParticipant = pList[(turn + idx) % pListSize]; + secondParticipant = pList[(turn + pListSize - idx) % pListSize]; + if (!firstParticipant.Equals(default) && !secondParticipant.Equals(default)) + // Alternate home/guest matches. + matches.Add(idx % 2 == 0 + ? (turn + 1, (TP) firstParticipant, (TP) secondParticipant) + : (turn + 1, (TP) secondParticipant, (TP) firstParticipant)); + } + } + + FixUnbalancedHomeGuestCountsInLastTurn(matches, numTurns, Participants); + + return matches; + } + + /// + /// After the last turn some participants have one more home match than guest matches. + /// This happens in the last turn of the round-robin system for an odd number of participants. + /// The method fixes the home/guest counts in the last turn of the matches list. + /// + /// Note: For odd numbers of participants, consecutive home/guest matches should be 1 for all. + /// This is currently only the case for 3 und 5 participants. + /// + /// + /// + /// + /// + private static void FixUnbalancedHomeGuestCountsInLastTurn( + IList<(int Turn, TP Home, TP Guest)> matches, int lastTurn, ICollection participants) + { + // Only odd number of participants require an adjustment + if (participants.Count % 2 == 0) return; + + // It's enough to check the counts for home matches: + // If the home count of a participant is bigger than the guest count, + // the opponents guest count is at the same time bigger than the home count. + var tooBigHomeCount = MatchesAnalyzer.GetUnbalancedHomeGuestCount(matches) + .Where(c => c.Value.HomeCount > c.Value.GuestCount) + .ToDictionary(hgc => hgc.Key, hgc => hgc.Value); + + for (var i = 0; i < matches.Count; i++) + { + if (matches[i].Turn != lastTurn) continue; + if (tooBigHomeCount.ContainsKey(matches[i].Home)) + { + matches[i] = (matches[i].Turn, matches[i].Guest, matches[i].Home); + } + } + } +} diff --git a/TournamentManager/TournamentManager/TournamentCreator.cs b/TournamentManager/TournamentManager/TournamentCreator.cs index 35ee7eeb..88714909 100644 --- a/TournamentManager/TournamentManager/TournamentCreator.cs +++ b/TournamentManager/TournamentManager/TournamentCreator.cs @@ -1,10 +1,10 @@ using SD.LLBLGen.Pro.ORMSupportClasses; using TournamentManager.DAL.EntityClasses; using TournamentManager.DAL.HelperClasses; +using TournamentManager.Data; using TournamentManager.MultiTenancy; - -namespace TournamentManager.Data; +namespace TournamentManager; /// /// The Copy class is used to copy an existing tournament @@ -24,7 +24,7 @@ public class TournamentCreator private readonly AppDb _appDb; private static TournamentCreator? _instance; - public TournamentCreator(IAppDb appDb) + private TournamentCreator(IAppDb appDb) { _appDb = (AppDb) appDb; } @@ -41,13 +41,16 @@ public static TournamentCreator Instance(IAppDb appDb) /// not exist. For start and end date of leg data 1 year is added. /// /// Existing source tournament id. + /// /// True, if creation was successful, false otherwise. - public async Task CopyTournament (long fromTournamentId) + public async Task CopyTournament (long fromTournamentId, CancellationToken cancellationToken) { var now = DateTime.UtcNow; - var tournament = await _appDb.TournamentRepository.GetTournamentAsync(new PredicateExpression(TournamentFields.Id == fromTournamentId), CancellationToken.None); - if (tournament is null) throw new NullReferenceException($"'{fromTournamentId}' not found."); - + var tournament = + await _appDb.TournamentRepository.GetTournamentAsync( + new PredicateExpression(TournamentFields.Id == fromTournamentId), CancellationToken.None) + ?? throw new InvalidOperationException($"'{fromTournamentId}' not found."); + var newTournament = new TournamentEntity { IsPlanningMode = true, @@ -77,13 +80,14 @@ public async Task CopyTournament (long fromTournamentId) /// /// Existing source tournament id. /// Existing target tournament id. - /// List of round id's to be excluded (may be null for none) + /// List of round id's to be excluded (empty list for 'none') + /// /// True, if creation was successful, false otherwise. - public bool CopyRound(long fromTournamentId, long toTournamentId, IEnumerable excludeRoundId) + public async Task CopyRound(long fromTournamentId, long toTournamentId, IList excludeRoundId, CancellationToken cancellationToken) { const string transactionName = "CloneRounds"; var now = DateTime.UtcNow; - + // get the rounds of SOURCE tournament var roundIds = _appDb.TournamentRepository.GetTournamentRounds(fromTournamentId).Select(r => r.Id).ToList(); @@ -92,7 +96,8 @@ public bool CopyRound(long fromTournamentId, long toTournamentId, IEnumerable(); foreach (var r in roundIds) { - roundsWithLegs.Enqueue(_appDb.RoundRepository.GetRoundWithLegs(r)); + var round = await _appDb.RoundRepository.GetRoundWithLegsAsync(r, cancellationToken); + if (round != null) roundsWithLegs.Enqueue(round); } foreach (var r in roundIds) @@ -134,8 +139,8 @@ public bool CopyRound(long fromTournamentId, long toTournamentId, IEnumerable rounds , int sequenceNo, DateTime start, DateTime end) + public async Task SetLegDates(IEnumerable rounds , int sequenceNo, DateTime start, DateTime end, CancellationToken cancellationToken) { //const string transactionName = "SetLegDates"; var now = DateTime.UtcNow; var roundEntities = (rounds as RoundEntity[] ?? rounds.ToArray()).ToList(); - if (!roundEntities.Any()) + if (roundEntities.Count == 0) return false; var roundIds = roundEntities.Select(r => r.Id).ToList(); roundEntities.Clear(); foreach (var rid in roundIds) { - roundEntities.Add(_appDb.RoundRepository.GetRoundWithLegs(rid)); + roundEntities.Add((await _appDb.RoundRepository.GetRoundWithLegsAsync(rid, cancellationToken))!); } var tournamentId = roundEntities.First().TournamentId; @@ -180,14 +185,14 @@ public bool SetLegDates(IEnumerable rounds , int sequenceNo, DateTi leg.EndDateTime = end; leg.ModifiedOn = now; - if (!da.SaveEntity(leg, false, false)) + if (!await da.SaveEntityAsync(leg, false, false, cancellationToken)) { da.Rollback(); return false; } } } - //da.Commit(); + //await da.CommitAsync(cancellationToken); return true; } @@ -196,44 +201,43 @@ public bool SetLegDates(IEnumerable rounds , int sequenceNo, DateTi /// If all matches of a tournament are completed, the rounds and the tournament are set to "completed". /// /// The Tournament to be set as "completed" + /// /// Throws an exception if any match of the tournament is not completed yet. - public async Task SetTournamentCompleted(long tournamentId) + public async Task SetTournamentCompleted(long tournamentId, CancellationToken cancellationToken) { - if (!new MatchRepository(_appDb.DbContext).AllMatchesCompleted(new TournamentEntity(tournamentId))) + if (!await (_appDb.MatchRepository.AllMatchesCompletedAsync(new TournamentEntity(tournamentId), cancellationToken))) { throw new ArgumentException($"Tournament {tournamentId} contains incomplete matches."); } - var tournament = await new TournamentRepository(_appDb.DbContext).GetTournamentWithRoundsAsync(tournamentId, CancellationToken.None); - if (tournament == null) throw new InvalidOperationException($"Tournament with Id '{tournamentId}' not found."); - + var tournament = await new TournamentRepository(_appDb.DbContext).GetTournamentWithRoundsAsync(tournamentId, CancellationToken.None) ?? throw new InvalidOperationException($"Tournament with Id '{tournamentId}' not found."); var now = DateTime.UtcNow; foreach (var round in tournament.Rounds) { - SetRoundCompleted(round); + await SetRoundCompleted(round, cancellationToken); } tournament.IsComplete = true; tournament.ModifiedOn = now; using var da = _appDb.DbContext.GetNewAdapter(); - if (!await da.SaveEntityAsync(tournament)) + if (!await da.SaveEntityAsync(tournament, cancellationToken)) { throw new ArgumentException($"Tournament Id {tournamentId} could not be saved to persistent storage."); } } - public virtual void SetRoundCompleted(RoundEntity round) + public virtual async Task SetRoundCompleted(RoundEntity round, CancellationToken cancellationToken) { - if (!new MatchRepository(_appDb.DbContext).AllMatchesCompleted(round)) + if (!await (_appDb.MatchRepository.AllMatchesCompletedAsync(round, cancellationToken))) throw new ArgumentException($"Round {round.Id} has uncompleted matches."); using var da = _appDb.DbContext.GetNewAdapter(); da.FetchEntity(round); round.IsComplete = true; round.ModifiedOn = DateTime.UtcNow; - da.SaveEntity(round); + await da.SaveEntityAsync(round, cancellationToken); da.CloseConnection(); } } diff --git a/TournamentManager/TournamentManager/TournamentManager.csproj b/TournamentManager/TournamentManager/TournamentManager.csproj index 7a37552a..817b9450 100644 --- a/TournamentManager/TournamentManager/TournamentManager.csproj +++ b/TournamentManager/TournamentManager/TournamentManager.csproj @@ -159,6 +159,10 @@ Volleyball League is an open source sports platform that brings everything neces <_Parameter1>$(AssemblyName).Tests + <_Parameter1>$(AssemblyName).Play + + + <_Parameter1>DynamicProxyGenAssembly2