From b7d35f84b3ce9915721c76059c4628e60eb9787c Mon Sep 17 00:00:00 2001 From: Rick Mason Date: Sat, 25 Jan 2025 15:26:43 +0000 Subject: [PATCH] Align existing tests to docs for linking and unlinking player identities, and get all tests back to passing #627 --- .../SqlServerPlayerDataSourceTests.cs | 14 +- .../SqlServerPlayerRepositoryTests.cs | 140 +++++++----- .../StoolballIntegrationTests.dacpac | Bin 32130 -> 32136 bytes ...axResultsDataSourceIntegrationTests.dacpac | Bin 32153 -> 32159 bytes .../SqlServerPlayerDataSource.cs | 6 +- .../SqlServerPlayerRepository.cs | 47 ++-- .../PlayersLinkedToMembersProvider.cs | 12 +- .../PlayersNotLinkedToMembersProvider.cs | 41 ++++ Stoolball.Testing/SeedDataGenerator.cs | 200 ++++++++++++------ Stoolball.UnitTests/Statistics/PlayerTests.cs | 31 ++- .../StatisticsBreadcrumbBuilderTests.cs | 2 +- .../BaseStatisticsTableControllerTests.cs | 26 +-- .../Statistics/CatchesControllerTests.cs | 9 +- ...dPlayersForMemberSurfaceControllerTests.cs | 7 +- .../PlayerBattingControllerTests.cs | 12 +- .../PlayerBowlingControllerTests.cs | 12 +- .../PlayerFieldingControllerTests.cs | 9 +- .../PlayerSummaryViewModelFactoryTests.cs | 12 +- .../Statistics/RunOutsControllerTests.cs | 9 +- Stoolball.Web/Teams/DeleteTeamController.cs | 2 +- .../Teams/DeleteTeamSurfaceController.cs | 2 +- Stoolball/Statistics/Player.cs | 2 +- Stoolball/Statistics/PlayerIdentityList.cs | 25 +++ Stoolball/StoolballEntityCopier.cs | 2 +- docs/Players.md | 2 +- 25 files changed, 402 insertions(+), 222 deletions(-) create mode 100644 Stoolball.Testing/PlayerDataProviders/PlayersNotLinkedToMembersProvider.cs create mode 100644 Stoolball/Statistics/PlayerIdentityList.cs diff --git a/Stoolball.Data.SqlServer.IntegrationTests/Statistics/SqlServerPlayerDataSourceTests.cs b/Stoolball.Data.SqlServer.IntegrationTests/Statistics/SqlServerPlayerDataSourceTests.cs index 1de88350..7e435b42 100644 --- a/Stoolball.Data.SqlServer.IntegrationTests/Statistics/SqlServerPlayerDataSourceTests.cs +++ b/Stoolball.Data.SqlServer.IntegrationTests/Statistics/SqlServerPlayerDataSourceTests.cs @@ -80,8 +80,9 @@ public async Task Read_players_supports_no_filter() public async Task Read_players_supports_filter_by_player_id() { var playerDataSource = new SqlServerPlayerDataSource(_connectionFactory, _routeNormaliser.Object, _statisticsQueryBuilder.Object); - var expectedPlayers = _testData.Players.Where(x => x.PlayerId != _testData.BowlerWithMultipleIdentities!.PlayerId).Take(3).ToList(); - expectedPlayers.Add(_testData.BowlerWithMultipleIdentities!); + var playerWithMultipleIdentities = _testData.PlayersWithMultipleIdentities.First(); + var expectedPlayers = _testData.Players.Where(x => x.PlayerId != playerWithMultipleIdentities.PlayerId).Take(3).ToList(); + expectedPlayers.Add(playerWithMultipleIdentities); var results = await playerDataSource.ReadPlayers(new PlayerFilter { PlayerIds = expectedPlayers.Select(x => x.PlayerId!.Value).ToList() }); @@ -236,7 +237,7 @@ public async Task Read_players_supports_exclude_by_player_identity_id() var players = _testData.PlayersWhoHavePlayedAtLeastOneMatch().ToList(); var playerDataSource = new SqlServerPlayerDataSource(_connectionFactory, _routeNormaliser.Object, _statisticsQueryBuilder.Object); var playerWithOneIdentity = players.First(x => x.PlayerIdentities.Count == 1); - var playerWithOtherIdentities = _testData.BowlerWithMultipleIdentities!; + var playerWithOtherIdentities = _testData.PlayersWithMultipleIdentities.First(); var identityToExcludeOfSeveral = playerWithOtherIdentities.PlayerIdentities.First().PlayerIdentityId!.Value; var results = await playerDataSource.ReadPlayers( @@ -498,7 +499,7 @@ private async Task Read_player_identities_supports_filter_by_involvement_in_a_se public async Task Read_player_identities_supports_filter_by_player_identity_id() { var playerDataSource = new SqlServerPlayerDataSource(_connectionFactory, _routeNormaliser.Object, _statisticsQueryBuilder.Object); - var identities = _testData.BowlerWithMultipleIdentities!.PlayerIdentities; + var identities = _testData.PlayersWithMultipleIdentities.First().PlayerIdentities; var results = await playerDataSource.ReadPlayerIdentities(new PlayerFilter { PlayerIdentityIds = identities.Select(x => x.PlayerIdentityId!.Value).ToList() }); @@ -727,11 +728,12 @@ public async Task Read_player_by_route_returns_all_identities_when_statistics_ar public async Task ReadPlayerByMemberKey_returns_PlayerRoute_for_matching_player() { var playerDataSource = new SqlServerPlayerDataSource(_connectionFactory, _routeNormaliser.Object, _statisticsQueryBuilder.Object); + var player = _testData.PlayersWithMultipleIdentities.First(p => p.MemberKey.HasValue); - var result = await playerDataSource.ReadPlayerByMemberKey(_testData.BowlerWithMultipleIdentities!.MemberKey!.Value); + var result = await playerDataSource.ReadPlayerByMemberKey(player.MemberKey!.Value); Assert.NotNull(result); - Assert.Equal(_testData.BowlerWithMultipleIdentities.PlayerRoute, result!.PlayerRoute); + Assert.Equal(player.PlayerRoute, result!.PlayerRoute); } [Fact] diff --git a/Stoolball.Data.SqlServer.IntegrationTests/Statistics/SqlServerPlayerRepositoryTests.cs b/Stoolball.Data.SqlServer.IntegrationTests/Statistics/SqlServerPlayerRepositoryTests.cs index 8e90965f..12591327 100644 --- a/Stoolball.Data.SqlServer.IntegrationTests/Statistics/SqlServerPlayerRepositoryTests.cs +++ b/Stoolball.Data.SqlServer.IntegrationTests/Statistics/SqlServerPlayerRepositoryTests.cs @@ -8,6 +8,7 @@ using Moq; using Newtonsoft.Json; using Stoolball.Data.Abstractions; +using Stoolball.Data.Abstractions.Models; using Stoolball.Data.SqlServer.IntegrationTests.Fixtures; using Stoolball.Data.SqlServer.IntegrationTests.Redirects; using Stoolball.Logging; @@ -70,11 +71,11 @@ private PlayerIdentity SetupCopyOfPlayerIdentity(PlayerIdentity playerIdentityTo Player = new Player { PlayerId = playerIdentityToUpdate.Player!.PlayerId, - PlayerIdentities = playerIdentityToUpdate.Player.PlayerIdentities.Select(x => new PlayerIdentity + PlayerIdentities = new PlayerIdentityList(playerIdentityToUpdate.Player.PlayerIdentities.Select(x => new PlayerIdentity { PlayerIdentityId = x.PlayerIdentityId, PlayerIdentityName = x.PlayerIdentityName - }).ToList() + })) } }; @@ -288,22 +289,22 @@ public async Task LinkPlayerIdentity_throws_InvalidOperationException_if_target_ var repo = CreateRepository(); if (isCurrentMember) { - var player1 = _testData.AnyPlayerLinkedToMemberWithOnlyOneIdentity(p => _testData.Players.Any(p2 => p2.MemberKey is null && p2.IsOnTheSameTeamAs(p))); - var player2 = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => p.IsOnTheSameTeamAs(player1)); - var currentMember = player1.MemberKey!.Value; - SetupMocksForLinkPlayerIdentity(player1, player2); + var targetPlayer = _testData.AnyPlayerLinkedToMemberWithOnlyOneIdentity(p => _testData.Players.Any(p2 => p2.MemberKey is null && p2.IsOnTheSameTeamAs(p))); + var playerWithIdentityToLink = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => p.IsOnTheSameTeamAs(targetPlayer)); + var currentMember = targetPlayer.MemberKey!.Value; + SetupMocksForLinkPlayerIdentity(targetPlayer, playerWithIdentityToLink); - var exception = await Record.ExceptionAsync(async () => await repo.LinkPlayerIdentity(player1.PlayerId!.Value, player2.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, currentMember, "Member name")); + var exception = await Record.ExceptionAsync(async () => await repo.LinkPlayerIdentity(targetPlayer.PlayerId!.Value, playerWithIdentityToLink.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, currentMember, "Member name")); Assert.Null(exception); } else { var currentMember = _testData.AnyMemberLinkedToPlayer(); - var player1 = _testData.AnyPlayerLinkedToMemberWithOnlyOneIdentity(p => p.MemberKey != currentMember.memberKey && _testData.Players.Any(p2 => p2.MemberKey is null && p2.IsOnTheSameTeamAs(p))); - var player2 = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => p.IsOnTheSameTeamAs(player1)); - SetupMocksForLinkPlayerIdentity(player1, player2); + var targetPlayer = _testData.AnyPlayerLinkedToMemberWithOnlyOneIdentity(p => p.MemberKey != currentMember.memberKey && _testData.Players.Any(p2 => p2.MemberKey is null && p2.IsOnTheSameTeamAs(p))); + var playerWithIdentityToLink = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => p.IsOnTheSameTeamAs(targetPlayer)); + SetupMocksForLinkPlayerIdentity(targetPlayer, playerWithIdentityToLink); - await Assert.ThrowsAsync(async () => await repo.LinkPlayerIdentity(player1.PlayerId!.Value, player2.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, currentMember.memberKey, currentMember.memberName)); + await Assert.ThrowsAsync(async () => await repo.LinkPlayerIdentity(targetPlayer.PlayerId!.Value, playerWithIdentityToLink.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, currentMember.memberKey, currentMember.memberName)); } } @@ -316,35 +317,45 @@ public async Task LinkPlayerIdentity_throws_InvalidOperationException_if_identit var repo = CreateRepository(); if (isCurrentMember) { - var player1 = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => _testData.Players.Any(p2 => p2.MemberKey is not null && p2.PlayerIdentities.Count == 1 && p2.IsOnTheSameTeamAs(p))); - var player2 = _testData.AnyPlayerLinkedToMemberWithOnlyOneIdentity(p => p.MemberKey is not null && p.IsOnTheSameTeamAs(player1)); - var currentMember = player2.MemberKey!.Value; - SetupMocksForLinkPlayerIdentity(player1, player2); - - var exception = await Record.ExceptionAsync(async () => await repo.LinkPlayerIdentity(player1.PlayerId!.Value, player2.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, currentMember, "Member name")); + var targetPlayer = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => p.PlayerIdentities[0].LinkedBy == PlayerIdentityLinkedBy.DefaultIdentity && + _testData.Players.Any(p2 => p2.MemberKey is not null && + p2.PlayerIdentities.Count == 1 && + p2.PlayerIdentities[0].LinkedBy == PlayerIdentityLinkedBy.Member && + p2.IsOnTheSameTeamAs(p))); + var playerWithIdentityToLink = _testData.AnyPlayerLinkedToMemberWithOnlyOneIdentity(p => p.MemberKey is not null && + p.IsOnTheSameTeamAs(targetPlayer) && + p.PlayerIdentities[0].LinkedBy == PlayerIdentityLinkedBy.Member + ); + var currentMember = playerWithIdentityToLink.MemberKey!.Value; + SetupMocksForLinkPlayerIdentity(targetPlayer, playerWithIdentityToLink); + + var exception = await Record.ExceptionAsync(async () => await repo.LinkPlayerIdentity(targetPlayer.PlayerId!.Value, playerWithIdentityToLink.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, currentMember, "Member name")); Assert.Null(exception); } else { var currentMember = _testData.AnyMemberLinkedToPlayer(); - var player1 = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => _testData.Players.Any(p2 => p2.MemberKey.HasValue && p2.MemberKey != currentMember.memberKey && p2.PlayerIdentities.Count == 1 && p2.IsOnTheSameTeamAs(p))); ; - var player2 = _testData.AnyPlayerLinkedToMemberWithOnlyOneIdentity(p => p.MemberKey != currentMember.memberKey && p.IsOnTheSameTeamAs(player1)); - SetupMocksForLinkPlayerIdentity(player1, player2); + var targetPlayer = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => _testData.Players.Any(p2 => p2.MemberKey.HasValue && p2.MemberKey != currentMember.memberKey && p2.PlayerIdentities.Count == 1 && p2.IsOnTheSameTeamAs(p))); ; + var playerWithIdentityToLink = _testData.AnyPlayerLinkedToMemberWithOnlyOneIdentity(p => p.MemberKey != currentMember.memberKey && p.IsOnTheSameTeamAs(targetPlayer)); + SetupMocksForLinkPlayerIdentity(targetPlayer, playerWithIdentityToLink); - await Assert.ThrowsAsync(async () => await repo.LinkPlayerIdentity(player1.PlayerId!.Value, player2.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, currentMember.memberKey, currentMember.memberName)); + await Assert.ThrowsAsync(async () => await repo.LinkPlayerIdentity(targetPlayer.PlayerId!.Value, playerWithIdentityToLink.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, currentMember.memberKey, currentMember.memberName)); } } [Fact] public async Task LinkPlayerIdentity_throws_InvalidOperationException_if_identity_to_link_is_linked_to_other_identities() { - var player1 = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(); - var player2 = _testData.AnyPlayerNotLinkedToMemberWithMultipleIdentities(p => p.IsOnTheSameTeamAs(player1) && - p.PlayerIdentities.Count == p.PlayerIdentities.Where(x => x.LinkedBy == PlayerIdentityLinkedBy.Team).Count()); - SetupMocksForLinkPlayerIdentity(player1, player2); + var targetPlayer = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => _testData.Players.Any(p2 => p2.IsOnTheSameTeamAs(p) && + !p2.MemberKey.HasValue && + p2.PlayerIdentities.Count > 1 && + p2.PlayerIdentities.Count == p2.PlayerIdentities.Where(x => x.LinkedBy == PlayerIdentityLinkedBy.Team).Count())); + var playerWithIdentityToLink = _testData.AnyPlayerNotLinkedToMemberWithMultipleIdentities(p => p.IsOnTheSameTeamAs(targetPlayer) && + p.PlayerIdentities.Count == p.PlayerIdentities.Where(x => x.LinkedBy == PlayerIdentityLinkedBy.Team).Count()); + SetupMocksForLinkPlayerIdentity(targetPlayer, playerWithIdentityToLink); var repo = CreateRepository(); - await Assert.ThrowsAsync(async () => await repo.LinkPlayerIdentity(player1.PlayerId!.Value, player2.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, Guid.NewGuid(), "Member name")); + await Assert.ThrowsAsync(async () => await repo.LinkPlayerIdentity(targetPlayer.PlayerId!.Value, playerWithIdentityToLink.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, Guid.NewGuid(), "Member name")); } [Fact] @@ -359,63 +370,88 @@ public async Task LinkPlayerIdentity_throws_InvalidOperationException_if_target_ } [Fact] - public async Task LinkPlayerIdentity_merges_players() + public async Task LinkPlayerIdentity_merges_two_default_identities() { - var player1 = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(); - var player2 = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => p.PlayerId != player1.PlayerId && p.IsOnTheSameTeamAs(player1)); + var targetPlayer = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(); + var playerWithIdentityToLink = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => p.PlayerId != targetPlayer.PlayerId && p.IsOnTheSameTeamAs(targetPlayer)); - SetupMocksForLinkPlayerIdentity(player1, player2); + SetupMocksForLinkPlayerIdentity(targetPlayer, playerWithIdentityToLink); var member = _testData.AnyMemberNotLinkedToPlayer(); var repo = CreateRepository(); - var movedIdentityResult = await repo.LinkPlayerIdentity(player1.PlayerId!.Value, player2.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, member.memberKey, member.memberName); + var movedIdentityResult = await repo.LinkPlayerIdentity(targetPlayer.PlayerId!.Value, playerWithIdentityToLink.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, member.memberKey, member.memberName); await repo.ProcessAsyncUpdatesForPlayers(); - Assert.Equal(player1.PlayerId, movedIdentityResult.PlayerIdForTargetPlayer); - Assert.Equal(player1.PlayerRoute, movedIdentityResult.PreviousRouteForTargetPlayer); - Assert.Equal(player1.MemberKey, movedIdentityResult.MemberKeyForTargetPlayer); + await AssertMergedPlayers(targetPlayer, playerWithIdentityToLink, movedIdentityResult); + } - Assert.Equal(player2.PlayerId, movedIdentityResult.PlayerIdForSourcePlayer); - Assert.Equal(player2.PlayerRoute, movedIdentityResult.PreviousRouteForSourcePlayer); - Assert.Equal(player2.MemberKey, movedIdentityResult.MemberKeyForSourcePlayer); + private async Task AssertMergedPlayers(Player targetPlayer, Player playerWithIdentityToLink, MovedPlayerIdentity movedIdentityResult) + { + Assert.Equal(targetPlayer.PlayerId, movedIdentityResult.PlayerIdForTargetPlayer); + Assert.Equal(targetPlayer.PlayerRoute, movedIdentityResult.PreviousRouteForTargetPlayer); + Assert.Equal(targetPlayer.MemberKey, movedIdentityResult.MemberKeyForTargetPlayer); - Assert.Equal(player2.PlayerRoute, movedIdentityResult.NewRouteForTargetPlayer); + Assert.Equal(playerWithIdentityToLink.PlayerId, movedIdentityResult.PlayerIdForSourcePlayer); + Assert.Equal(playerWithIdentityToLink.PlayerRoute, movedIdentityResult.PreviousRouteForSourcePlayer); + Assert.Equal(playerWithIdentityToLink.MemberKey, movedIdentityResult.MemberKeyForSourcePlayer); + + Assert.Equal(playerWithIdentityToLink.PlayerRoute, movedIdentityResult.NewRouteForTargetPlayer); using (var connectionForAssert = _connectionFactory.CreateDatabaseConnection()) { connectionForAssert.Open(); - var player1Updated = await connectionForAssert.QuerySingleAsync<(string PlayerRoute, Guid? MemberKey)>($"SELECT PlayerRoute, MemberKey FROM {Tables.Player} WHERE PlayerId = @PlayerId", player1); - Assert.Equal(player2.PlayerRoute, player1Updated.PlayerRoute); - Assert.Null(player1Updated.MemberKey); + var targetPlayerUpdated = await connectionForAssert.QuerySingleAsync<(string PlayerRoute, Guid? MemberKey)>($"SELECT PlayerRoute, MemberKey FROM {Tables.Player} WHERE PlayerId = @PlayerId", targetPlayer); + Assert.Equal(playerWithIdentityToLink.PlayerRoute, targetPlayerUpdated.PlayerRoute); + Assert.Null(targetPlayerUpdated.MemberKey); - var expectedIdentities = player1.PlayerIdentities.Select(pi => pi).ToList(); - expectedIdentities.Add(player2.PlayerIdentities[0]); + var expectedIdentities = targetPlayer.PlayerIdentities.Select(pi => pi).ToList(); + expectedIdentities.Add(playerWithIdentityToLink.PlayerIdentities[0]); foreach (var identity in expectedIdentities) { var playerIdentity = await connectionForAssert.QuerySingleAsync<(Guid PlayerId, string LinkedBy)>($"SELECT PlayerId, LinkedBy FROM {Tables.PlayerIdentity} WHERE PlayerIdentityId = @PlayerIdentityId", identity); - Assert.Equal(player1.PlayerId, playerIdentity.PlayerId); + Assert.Equal(targetPlayer.PlayerId, playerIdentity.PlayerId); Assert.Equal(PlayerIdentityLinkedBy.Team.ToString(), playerIdentity.LinkedBy); var playerIdsInStatistics = await connectionForAssert.QueryAsync($"SELECT PlayerId FROM {Tables.PlayerInMatchStatistics} WHERE PlayerIdentityId = @PlayerIdentityId", identity); foreach (var playerIdInStatistics in playerIdsInStatistics) { - Assert.Equal(player1.PlayerId, playerIdInStatistics); + Assert.Equal(targetPlayer.PlayerId, playerIdInStatistics); } - var playerRoutesInStatistics = await connectionForAssert.QueryAsync($"SELECT PlayerRoute FROM {Tables.PlayerInMatchStatistics} WHERE PlayerId = @PlayerId", player1); + var playerRoutesInStatistics = await connectionForAssert.QueryAsync($"SELECT PlayerRoute FROM {Tables.PlayerInMatchStatistics} WHERE PlayerId = @PlayerId", targetPlayer); foreach (var route in playerRoutesInStatistics) { - Assert.Equal(player2.PlayerRoute, route); + Assert.Equal(playerWithIdentityToLink.PlayerRoute, route); } } - var obsoletePlayerShouldBeRemoved = await connectionForAssert.QuerySingleOrDefaultAsync($"SELECT COUNT(PlayerId) FROM {Tables.Player} WHERE PlayerId = @PlayerId", player2); + var obsoletePlayerShouldBeRemoved = await connectionForAssert.QuerySingleOrDefaultAsync($"SELECT COUNT(PlayerId) FROM {Tables.Player} WHERE PlayerId = @PlayerId", playerWithIdentityToLink); Assert.Equal(0, obsoletePlayerShouldBeRemoved); } } + [Fact] + public async Task LinkPlayerIdentity_merges_players_if_target_is_linked_to_other_identities_on_the_same_team() + { + var playerWithIdentityToLink = _testData.AnyPlayerNotLinkedToMemberWithOnlyOneIdentity(p => _testData.Players.Any(p2 => p2.IsOnTheSameTeamAs(p) && + !p2.MemberKey.HasValue && + p2.PlayerIdentities.Count > 1 && + p2.PlayerIdentities.Count == p2.PlayerIdentities.Where(x => x.LinkedBy == PlayerIdentityLinkedBy.Team).Count() + )); + var targetPlayer = _testData.AnyPlayerNotLinkedToMemberWithMultipleIdentities(p => p.IsOnTheSameTeamAs(playerWithIdentityToLink) && + p.PlayerIdentities.Count == p.PlayerIdentities.Where(x => x.LinkedBy == PlayerIdentityLinkedBy.Team).Count()); + SetupMocksForLinkPlayerIdentity(targetPlayer, playerWithIdentityToLink); + + var repo = CreateRepository(); + var movedIdentityResult = await repo.LinkPlayerIdentity(targetPlayer.PlayerId!.Value, playerWithIdentityToLink.PlayerIdentities[0].PlayerIdentityId!.Value, PlayerIdentityLinkedBy.Team, Guid.NewGuid(), "Member name"); + await repo.ProcessAsyncUpdatesForPlayers(); + + await AssertMergedPlayers(targetPlayer, playerWithIdentityToLink, movedIdentityResult); + } + + [Fact] public async Task LinkPlayerIdentity_redirects_deleted_player_to_linked_player() { @@ -475,7 +511,8 @@ public async Task UnlinkPlayerIdentity_throws_InvalidOperationException_for_last [Fact] public async Task UnlinkPlayerIdentity_for_penultimate_identity_moves_identity_to_new_player_including_statistics() { - var player = _testData.AnyPlayerNotLinkedToMemberWithMultipleIdentities(p => p.PlayerIdentities.Count == 2); + var player = _testData.AnyPlayerNotLinkedToMemberWithMultipleIdentities(p => p.PlayerIdentities.Count == 2 && + p.PlayerIdentities.Count(pi => pi.LinkedBy == PlayerIdentityLinkedBy.Team) == 2); var identityToKeep = player.PlayerIdentities[0]; var identityToUnlink = player.PlayerIdentities[1]; @@ -491,8 +528,9 @@ public async Task UnlinkPlayerIdentity_for_penultimate_identity_moves_identity_t { connectionForAssert.Open(); - var originalPlayerStillLinked = await connectionForAssert.ExecuteScalarAsync($"SELECT PlayerId FROM {Tables.PlayerIdentity} WHERE PlayerIdentityId = @PlayerIdentityId", identityToKeep).ConfigureAwait(false); - Assert.Equal(player.PlayerId, originalPlayerStillLinked); + var originalPlayerStillLinked = await connectionForAssert.QuerySingleAsync<(Guid PlayerId, PlayerIdentityLinkedBy LinkedBy)>($"SELECT PlayerId, LinkedBy FROM {Tables.PlayerIdentity} WHERE PlayerIdentityId = @PlayerIdentityId", identityToKeep).ConfigureAwait(false); + Assert.Equal(player.PlayerId, originalPlayerStillLinked.PlayerId); + Assert.Equal(PlayerIdentityLinkedBy.DefaultIdentity, originalPlayerStillLinked.LinkedBy); var newPlayerLinkedToIdentity = await connectionForAssert.QuerySingleAsync<(Guid? playerId, string route, PlayerIdentityLinkedBy linkedBy)>( $@"SELECT p.PlayerId, p.PlayerRoute, p.LinkedBy diff --git a/Stoolball.Data.SqlServer.IntegrationTests/StoolballIntegrationTests.dacpac b/Stoolball.Data.SqlServer.IntegrationTests/StoolballIntegrationTests.dacpac index 1ef634a6e07f08db8d7e94c31e3640621dcf56c1..684218a88ff92eb138d48cb0ccbd6993f2d7cebd 100644 GIT binary patch delta 8791 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 PdV--JB~qyGzvTY|{$kf? delta 8791 zcmY+KWlWt-*sV8i#kIJ*+s56U;!xb(T^^j`RwxvAcP;J|hXNaSC~l>=oww&C=gW6b zR%T6dUz5!Im}HXm}uTg1alG$Xq-3PsF1#N zjofgVo#||6Ga`324;*b8cvZP8^DFPPyeORE@MBqg;ijUZ&=IV=zAn~1AxbFNFU+ea_@P)FS-$NpmoUO%Kx zq!f{_Qh!IB_yL~*n@m_exS_fT1RMG5=@;944-0_cJJuncGA3*smlSxar= z);Vk6x_JaGVE#;vt*?oQC{C=OJRNsA#J1wCw`j-D$5>dV9gxV|nNE}T+pr%9=NOe} zU}vn%$(xzE=J}ncezcYnt^4V?7nEzLQt<36;0(#EvTie?reEFV{40E~SBDGJdW5l4 zy5HDWuK^}S0y0Kz!HK6i(Foz%R9kEpBZo&w()0cZ=$SKaSWbhvNx3C~G4TcMF9S3& z^~I_Ze~gpbT)PEDQxx+SbRcx%^XtxS-32*M@`MF!D*Kksvy+hDis*9!g|)p*v8A%7 z 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 diff --git a/Stoolball.Data.SqlServer.IntegrationTests/StoolballStatisticsMaxResultsDataSourceIntegrationTests.dacpac b/Stoolball.Data.SqlServer.IntegrationTests/StoolballStatisticsMaxResultsDataSourceIntegrationTests.dacpac index 572705bf15ec8da16790b08d25c15b61c679c9d0..df76e3af7b8964bd9efe80a93847ef60b72354c0 100644 GIT binary patch delta 8779 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 delta 8770 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

uvqLlHe#M% zmYz{rDn5UKxt}%z#*!bV4{S3L^m_k51ah<6dKZrXmf|8 z4g(6Ldaso`G=8w+rcChNxU3&+s2u1p{5`++$IpfdOZ4$BgeQshDeQFlSXVXqR>HS8 z%mLDxV?fy>3#N>K*>1VKjeX;~OMXHr67ZyOJFFSIlor1K zxz}4to03%h?|Ik3w8S3*MN!F2ObwD+`cuU-#XmQS6rVN<5M~SxqLqT>8h(iY7S^_U zvvqbdg*zFyHT#4%Kj;GF8|UWPB?iI4ENlp85$f?}i0Z8%lO&$og$1J1(9G z4rb}f37ZU)S+R^I;;DT_Ci&r$#I2%|OeIgA93!7q#S3;=uzxYa$fxQLYd)i2-$@|4 z)BTN8jvz3OPm%CwaML2OjnW5BxP;o8%zH;@_WUxcwcrq-AGU^QAXnrPNQYZFXF8XI z(L!qVWL<5_Ek(*^QZRl8UlDf-y@9WVe?ev9 z0yK=Ae6ic3n@%0nuPNDvL&c*7l~4zHzX=Je=KJxze@};EP=fJnXtK`)PFZ#|mm~S>QebOmD z-}5H5j8NDg<uLQu96aGH(L9+;fj@{5^SyW3iAs3)2jQLs5F*8`RqSn-$)CGUezdC z)u=MS{xDsukx|n`nS*-;-_+cqA3xB@aatHXLY_;rVc^ejz-5xvi7OjQXhEjdd_@2 z_a=sSpB&ndVcLucyD?;0f(UvV`uiX2Cv_}BLo_ujJJXO~O*)jvtLNGC8fAml4bzD5 z>K0Hlc6vEpBly|bIO`;8KW{k)dD`n3%#`Y!%!R=KOP>l7?s*prSMR?oG9yftokHV> z7Q}t%H#yK<+3>@6FX7zDLW>t?l+#EM?M{&Fq7aE{n%~zvX`@v}InS+8ppY)%;9n1ZOV|`>T>a}1zXXix=f_iaLS{SzoWhi zbV5&4HB&3Yl#SM#XEZe%xGtjAw$nUkD9en+O8nABdOuxgjv=Jx(1TXLQpsQvVgpx< zrQf9a#T3`=I%Uph9+*o=AU{O$d>D9Zj-y$?v4JP%cuM(a)npdxqUT|v4Y?p+#AJfH zfsenMc=`Kntkjv>!&}jJ6k2(~=|d4QrDdhI7FWyMS5UhR%>_j!V2m)<_DySN8OdefjI;;?q*}Q)Qto@=txT{NA*+`Q7hAp{zCMc zNv*Sg#;|3V2)-xWVm^sM&>L*=#})#jK_eM-c7bJ@&)^9@ODUJ;y zHI>f(TDg3e2=GA{%;^{2y^wwwNAFDBAdlJDvQD%`6`CbN)8+0EQrIP8(?5W`#d(bv z;*S5ge~_Q7(DTHyFZ-q4amRRuu_T4-=qqzxo?U~ZNjgFLxy*}|E&_tm-GCk;Z*yHB z=4z51c|$RgWMWM>mtIUtfgEbBy|YVMa)8hOd^&FX9zXZRuAT6QXbUtelcbjBw@r80 zCazlu=Umx)9|=t}eoNRn0D5zbYBNw6N?ATRc<;z=ROB*Q`CD8hft6Il3^kyg`d1^#;9qNpCnCYNOx1zTU0udh-Z%m(^^4oY&1e*H!X7)x^bMBrz($aTQk)qn3l~>qyqVRww$KRO`T|Oo7c6 zGtjH$w|=4b?j+CU9{cro%V5{{E3A5@Lg=Z=2^#f%@P;lhKBd@dqGN7$;pY(~Iy{8+ zQgI&SSo`t1Y4@6@K(nSUHzQFrj~uN>W@W!9r}T4%vDehW<*3~C^t9noF5kXK9UL0# zyR3Ok!_GIH^=gv;>4~_6NOw;arYr?K3V=l5zX)ZEH9c7^-`d>eo_vb^s zsi(&m=KNnGdcFcG?TLb(zlMVTD3aZLhmZ{sxDb&oJWiD);xy;#$;D9E|7)>j5Wx1Z zjJLY!946?f43lG4gF+DcziFA*p!hPY0qEMfrgZDby)0!Vn#V!n6YYa+H05Fp)T#;x zF?s(rnfWZh@&7KNFexYuKGpjVs84)!QzDD((G7GeJuDmsAOupvyd|nGQ@!autOTkbR>_8>ELWXdhe**fW#vF9C@!z%rqHqJN zVWc)N#4PESLPr(NBN8BI@6t9|u#dy?-<%g7cy;y@oUT^CUdY%9z1* z3o^oiTNfA2K|Nm3B%t*+y1L}PgrX%ONlYu~-CM(evyKj)jn%NCrem z!7Hi67&MQ`0URdjb2xyy7cqVdR<+Rb1dP?{5-xrX$a@=I5p>yj=6mE8ee>dQIK0Rz zm=OG(1rf8vq=H>D=xE~A<3swvFGQj?UL41Elh~%zdP1>%$0O1-f(J5ryq-1@?V5zz zjx`s$E0oTaaW!yyM(7|T%^}o8uk?=?=gtJ4wFepo0vq&J3pdwj^?!{|Ur5Lgy6t~G zWj%8EE+U+tjXLbDk$!lr&@Rc2DqzCQBQC=ZD9@yJA^ALnVYNsQR0q)LeVO~wdZshG z-5xak++DRITv7nRpo9v zFjN^$+Ov|5i3HrJw@F17|E-~LdfyZLML76orqWm^X!Sl_B-m^1Ppz+)$?n%|I@%l^ zj;l24c{v-e)x4$Fa6y-TiA}olSdqphU6(TvSHQe21Lp?}&u^wUa=O-f8x+a)qE5|K zaYl9V3UlGD`=;|7hmmrpxm;2aq^VyX*HyBH44ACJ+Ix8uuo681+7apx6;9kFd}9o( zJ7#Skw{Mx;ExRi`9q{?Ik>E_6}0$h!FNDp{VrD?%!R$ z*a5c2Z<+JZs!eI{R2LE?$ItM-2MM0<9m%fBRVwbCt3SaNSARlJkw5-r)s8PQf;##& zyj(|`hFmc-if=mBHZC}IeeYHe`;5x>msOodaz!z85UIF22Hnk!`Ga*duY{cQKLRRE zBdGF^_+}d?^W`6bdj^y8j{ttw{f8te8sd@^{M9O&z;FmlEKKD<(yWe=t;**{cgp3R zC3w%=F?v={zcQd%JZ^dbaz3ew;Z9Uku#m(eqak6sj$(Ctm&|?U=z)nZD_jB9gbXz@ zNqt5U@s2wbv07QhGW1x*GF?9FHd1!rkmS6;>Vo9SN?_r1aJ^GZ268Rl;gqXokF-EH z%VI~Y)J5X4WRG^v*<}^d#BW5jV{Z=XUjJV0s-2k(>o|SM_J>*dJ-Yl==@kOJK=vjy zJc$rd%AIfGx&|sOK2riN7k`d-F7FQ?k9}RFAS)y)$By+bC-mbrbrE2pb3#lJxAmJV zI~_N%-QCr=Mc1?kfJR2cM-D&TE+OlopfP@3bB{GnENia&lzUcIRazCchTZC$dl*5{ z?J@!@6saMk{WT-*g{P@gX7=LHFX+QzTI=0TDmA=7&8C$=jqcyH&<|)AzLYNpT|F2@ z&Bre??Og(jUeycHoOUL&`F=g>-`|gaww?ENcrlEPXZ%Gj zk<@J1pDm*rPceysd;z+j}1K5BfZZW!^RnLe9$;i|)+x+QUQ&it3>GeKX*CR2|{u_QK= zDBEX0$KblIT#;Yl)gr|dqMe%K8V3G~J@H9B>FmF}ZU=6if2p}l_Y3^F%FEjz7l=uX z6Y05qgZux*hg5a<3;2HqM5>fWC-T2yZgYv-h~&8gLks2ubMbI<@v`xonSMT34U%cn3r3Y zUzSIT`@h1^DJ><*#mO%#CBVfkDJuz)PL&F@LjV7yjnw8qW{Q84V1T=B_&=%R?7v|C zm;XD$sjq?L|GOw<5HT!=N2-PgK8v~%3@je#p9TW@9}VQ+?f^0f^e?FY2mjYDQUim? K5Yq$y4gVh@(bgLP diff --git a/Stoolball.Data.SqlServer/SqlServerPlayerDataSource.cs b/Stoolball.Data.SqlServer/SqlServerPlayerDataSource.cs index 46a932cf..4486f903 100644 --- a/Stoolball.Data.SqlServer/SqlServerPlayerDataSource.cs +++ b/Stoolball.Data.SqlServer/SqlServerPlayerDataSource.cs @@ -77,7 +77,7 @@ public async Task> ReadPlayers(PlayerFilter? filter, IDbConnection return rawResults.GroupBy(x => x.PlayerId).Select(group => { var player = group.First(); - player.PlayerIdentities = group.Select(x => x.PlayerIdentities.Single()).OfType().ToList(); + player.PlayerIdentities = new PlayerIdentityList(group.Select(x => x.PlayerIdentities.Single()).OfType()); return player; }).ToList(); } @@ -116,7 +116,7 @@ public async Task> ReadPlayerIdentities(PlayerFilter? filte // Populate the PlayerIdentities collections of the players with the data that we have foreach (var identity in identities) { - identity.Player!.PlayerIdentities = identities.Where(x => x.Player?.PlayerId == identity.Player.PlayerId).ToList(); + identity.Player!.PlayerIdentities = new PlayerIdentityList(identities.Where(x => x.Player?.PlayerId == identity.Player.PlayerId)); } return identities; @@ -243,7 +243,7 @@ WHERE LOWER(PlayerRoute) = @Route var playerToReturn = playerData.GroupBy(x => x.PlayerId).Select(group => { var player = group.First(); - player.PlayerIdentities = group.Select(x => x.PlayerIdentities.Single()).OfType().ToList(); + player.PlayerIdentities = new PlayerIdentityList(group.Select(x => x.PlayerIdentities.Single()).OfType()); return player; }).FirstOrDefault(); diff --git a/Stoolball.Data.SqlServer/SqlServerPlayerRepository.cs b/Stoolball.Data.SqlServer/SqlServerPlayerRepository.cs index d20db60e..cff25821 100644 --- a/Stoolball.Data.SqlServer/SqlServerPlayerRepository.cs +++ b/Stoolball.Data.SqlServer/SqlServerPlayerRepository.cs @@ -313,11 +313,11 @@ public async Task LinkPlayerIdentity(Guid targetPlayer, Gui connection.Open(); using (var transaction = connection.BeginTransaction()) { - var targetPlayerBefore = await connection.QuerySingleAsync<(string PlayerRoute, Guid? MemberKey)>($"SELECT PlayerRoute, MemberKey FROM {Tables.Player} WHERE PlayerId = @PlayerId", new Player { PlayerId = targetPlayer }, transaction); + var targetPlayerBefore = await connection.QuerySingleAsync<(string PlayerRoute, Guid? MemberKey)>($"SELECT PlayerRoute, MemberKey FROM {Tables.Player} WHERE PlayerId = @PlayerId", new Player { PlayerId = targetPlayer }, transaction).ConfigureAwait(false); var identityToLinkBefore = await connection.QuerySingleAsync<(Guid PlayerId, string PlayerRoute, Guid? MemberKey, int Identities, string PlayerIdentityName, Guid TeamId)>( @$"SELECT p.PlayerId, p.PlayerRoute, p.MemberKey, (SELECT COUNT(*) FROM {Tables.PlayerIdentity} WHERE PlayerId = p.PlayerId) AS Identities, pi.PlayerIdentityName, pi.TeamId FROM {Tables.PlayerIdentity} pi INNER JOIN {Tables.Player} p ON pi.PlayerId = p.PlayerId - WHERE pi.PlayerIdentityId = @PlayerIdentityId", new { PlayerIdentityId = identityToLinkToTarget }, transaction); + WHERE pi.PlayerIdentityId = @PlayerIdentityId", new { PlayerIdentityId = identityToLinkToTarget }, transaction).ConfigureAwait(false); // Are the players already linked to each other? If so, abort. if (targetPlayer == identityToLinkBefore.PlayerId) @@ -353,7 +353,7 @@ public async Task LinkPlayerIdentity(Guid targetPlayer, Gui // Does the target player have an identity on the same team as the identity to link? If not, abort. var targetPlayerExistingIdentities = await connection.QueryAsync<(Guid PlayerIdentityId, string PlayerIdentityName, Guid TeamId)>( $"SELECT PlayerIdentityId, PlayerIdentityName, TeamId FROM {Tables.PlayerIdentity} WHERE PlayerId = @PlayerId", - new { PlayerId = targetPlayer }, transaction); + new { PlayerId = targetPlayer }, transaction).ConfigureAwait(false); var targetPlayerHasIdentityOnSameTeam = targetPlayerExistingIdentities.Any(id => id.TeamId == identityToLinkBefore.TeamId); if (!targetPlayerHasIdentityOnSameTeam) @@ -367,19 +367,23 @@ public async Task LinkPlayerIdentity(Guid targetPlayer, Gui // Move the player identities from the identity to link's current player id to the target player's id. if (bestRoute != targetPlayerBefore.PlayerRoute) { - await connection.ExecuteAsync($"UPDATE {Tables.Player} SET PlayerRoute = @PlayerRoute WHERE PlayerId = @PlayerId", new { PlayerRoute = bestRoute, PlayerId = targetPlayer }, transaction); + _ = await connection.ExecuteAsync($"UPDATE {Tables.Player} SET PlayerRoute = @PlayerRoute WHERE PlayerId = @PlayerId", new { PlayerRoute = bestRoute, PlayerId = targetPlayer }, transaction).ConfigureAwait(false); } var movePlayerIdentity = new { LinkedBy = linkedBy.ToString(), PlayerId = targetPlayer, PlayerIdentityId = identityToLinkToTarget }; - await connection.ExecuteAsync($"UPDATE {Tables.PlayerIdentity} SET LinkedBy = @LinkedBy, PlayerId = @PlayerId WHERE PlayerIdentityId = @PlayerIdentityId", movePlayerIdentity, transaction); + _ = await connection.ExecuteAsync($"UPDATE {Tables.PlayerIdentity} SET LinkedBy = @LinkedBy, PlayerId = @PlayerId WHERE PlayerIdentityId = @PlayerIdentityId", movePlayerIdentity, transaction).ConfigureAwait(false); + + // If the target player has and identities with LinkedBy = DefaultIdentity, they should now be linked by this activity + _ = await connection.ExecuteAsync($"UPDATE {Tables.PlayerIdentity} SET LinkedBy = @LinkedBy WHERE PlayerId = @PlayerId AND LinkedBy = '{PlayerIdentityLinkedBy.DefaultIdentity.ToString()}'", + new { LinkedBy = linkedBy.ToString(), PlayerId = targetPlayer }, transaction).ConfigureAwait(false); // We also need to update statistics, and delete the now-unused player that the identity has been moved away from. // However this is done asynchronously by ProcessAsyncUpdatesForPlayers, so we just need to mark the player as safe to delete. - await connection.ExecuteAsync($"UPDATE {Tables.Player} SET Deleted = 1 WHERE PlayerId = @PlayerId", new { identityToLinkBefore.PlayerId }, transaction); + _ = await connection.ExecuteAsync($"UPDATE {Tables.Player} SET Deleted = 1 WHERE PlayerId = @PlayerId", new { identityToLinkBefore.PlayerId }, transaction).ConfigureAwait(false); var deletedPlayer = new Player { PlayerId = identityToLinkBefore.PlayerId, PlayerRoute = identityToLinkBefore.PlayerRoute }; deletedPlayer.PlayerIdentities.Add(new PlayerIdentity { PlayerIdentityId = identityToLinkToTarget }); var serialisedDeletedPlayer = JsonConvert.SerializeObject(deletedPlayer); - await _auditRepository.CreateAudit(new AuditRecord + _ = await _auditRepository.CreateAudit(new AuditRecord { Action = AuditAction.Delete, MemberKey = memberKey, @@ -388,7 +392,7 @@ await _auditRepository.CreateAudit(new AuditRecord State = serialisedDeletedPlayer, RedactedState = serialisedDeletedPlayer, AuditDate = DateTime.UtcNow - }, transaction); + }, transaction).ConfigureAwait(false); _logger.Info(LoggingTemplates.Deleted, serialisedDeletedPlayer, memberName, memberKey, GetType(), nameof(LinkPlayerIdentity)); @@ -399,7 +403,7 @@ await _auditRepository.CreateAudit(new AuditRecord updatedTargetPlayer.PlayerIdentities.Add(reassignedPlayerIdentity); var serialisedUpdatedPlayer = JsonConvert.SerializeObject(updatedTargetPlayer); - await _auditRepository.CreateAudit(new AuditRecord + _ = await _auditRepository.CreateAudit(new AuditRecord { Action = AuditAction.Update, MemberKey = memberKey, @@ -408,7 +412,7 @@ await _auditRepository.CreateAudit(new AuditRecord State = serialisedUpdatedPlayer, RedactedState = serialisedUpdatedPlayer, AuditDate = DateTime.UtcNow - }, transaction); + }, transaction).ConfigureAwait(false); _logger.Info(LoggingTemplates.Updated, serialisedUpdatedPlayer, memberName, memberKey, GetType(), nameof(LinkPlayerIdentity)); @@ -560,19 +564,30 @@ public async Task UnlinkPlayerIdentity(Guid identityToUnlink, Guid memberKey, st connection.Open(); using (var transaction = connection.BeginTransaction()) { - var (totalIdentitiesLinkedToPlayer, playerId, playerIdentityName) = await connection.QuerySingleAsync<(int totalIdentitiesLinkedToMember, Guid playerId, string playerIdentityName)>( - $@"SELECT COUNT(*), PlayerId, (SELECT PlayerIdentityName FROM {Views.PlayerIdentity} WHERE PlayerIdentityId = @PlayerIdentityId) AS PlayerIdentityName + var identitiesForPlayer = (await connection.QueryAsync<(Guid PlayerId, Guid PlayerIdentityId, string Name, PlayerIdentityLinkedBy LinkedBy)>( + $@"SELECT PlayerId, PlayerIdentityId, PlayerIdentityName, LinkedBy FROM {Views.PlayerIdentity} - WHERE PlayerId = (SELECT PlayerId FROM {Views.PlayerIdentity} WHERE PlayerIdentityId = @PlayerIdentityId) - GROUP BY PlayerId", new { PlayerIdentityId = identityToUnlink }, transaction).ConfigureAwait(false); + WHERE PlayerId = (SELECT PlayerId FROM {Views.PlayerIdentity} WHERE PlayerIdentityId = @PlayerIdentityId)", + new { PlayerIdentityId = identityToUnlink }, transaction).ConfigureAwait(false)).ToList(); - if (totalIdentitiesLinkedToPlayer == 1) + if (identitiesForPlayer.Count == 1) { throw new InvalidOperationException(); } else { - await MoveIdentityToNewPlayer(identityToUnlink, playerIdentityName, memberKey, memberName, transaction, nameof(UnlinkPlayerIdentity)).ConfigureAwait(false); + var identityToUnlinkName = identitiesForPlayer.Single(pi => pi.PlayerIdentityId == identityToUnlink).Name; + await MoveIdentityToNewPlayer(identityToUnlink, identityToUnlinkName, memberKey, memberName, transaction, nameof(UnlinkPlayerIdentity)).ConfigureAwait(false); + + var remainingIdentities = identitiesForPlayer.Where(pi => pi.PlayerIdentityId != identityToUnlink).ToList(); + if (remainingIdentities.Count == 1 && + remainingIdentities[0].LinkedBy == PlayerIdentityLinkedBy.Team || remainingIdentities[0].LinkedBy == PlayerIdentityLinkedBy.StoolballEngland) + { + _ = await connection.ExecuteAsync($"UPDATE {Tables.PlayerIdentity} SET LinkedBy = @LinkedBy WHERE PlayerIdentityId = @PlayerIdentityId", + new { LinkedBy = PlayerIdentityLinkedBy.DefaultIdentity.ToString(), remainingIdentities[0].PlayerIdentityId }, + transaction).ConfigureAwait(false); + + } } transaction.Commit(); diff --git a/Stoolball.Testing/PlayerDataProviders/PlayersLinkedToMembersProvider.cs b/Stoolball.Testing/PlayerDataProviders/PlayersLinkedToMembersProvider.cs index a415ef37..ef16851a 100644 --- a/Stoolball.Testing/PlayerDataProviders/PlayersLinkedToMembersProvider.cs +++ b/Stoolball.Testing/PlayerDataProviders/PlayersLinkedToMembersProvider.cs @@ -5,9 +5,8 @@ using Stoolball.Statistics; using Stoolball.Teams; using Stoolball.Testing.Fakers; -using Stoolball.Testing.PlayerDataProviders; -namespace Stoolball.Testing.TeamDataProviders +namespace Stoolball.Testing.PlayerDataProviders { internal class PlayersLinkedToMembersProvider(IFakerFactory teamFakerFactory, IFakerFactory playerFakerFactory, IFakerFactory playerIdentityFakerFactory) : BasePlayerDataProvider { @@ -44,15 +43,6 @@ internal override IEnumerable CreatePlayers(TestData readOnlyTestData) identity.LinkedBy = PlayerIdentityLinkedBy.Member; } - for (var i = 0; i < 2; i++) - { - players[i].PlayerIdentities.Add(identities[i]); - identities[i].Player = players[i]; - identities[i].Team = team; - identities[i].LinkedBy = PlayerIdentityLinkedBy.Member; - players[i].MemberKey = Guid.NewGuid(); - } - // another player on the same team, not linked to a member var playerWithoutMember = _playerFaker.Generate(1).Single(); playerWithoutMember.PlayerIdentities.Add(identities[2]); diff --git a/Stoolball.Testing/PlayerDataProviders/PlayersNotLinkedToMembersProvider.cs b/Stoolball.Testing/PlayerDataProviders/PlayersNotLinkedToMembersProvider.cs new file mode 100644 index 00000000..da3b8ba1 --- /dev/null +++ b/Stoolball.Testing/PlayerDataProviders/PlayersNotLinkedToMembersProvider.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using Bogus; +using Stoolball.Statistics; +using Stoolball.Teams; +using Stoolball.Testing.Fakers; + +namespace Stoolball.Testing.PlayerDataProviders +{ + internal class PlayersNotLinkedToMembersProvider(IFakerFactory teamFakerFactory, IFakerFactory playerFakerFactory, IFakerFactory playerIdentityFakerFactory) : BasePlayerDataProvider + { + private readonly Faker _teamFaker = teamFakerFactory.Create(); + private readonly Faker _playerFaker = playerFakerFactory.Create(); + private readonly Faker _playerIdentityFaker = playerIdentityFakerFactory.Create(); + + internal override IEnumerable CreatePlayers(TestData readOnlyTestData) + { + var team = _teamFaker.Generate(1).Single(); + + // player with a single identity + var playerWithSingleIdentity = _playerFaker.Generate(1).Single(); + playerWithSingleIdentity.PlayerIdentities.Add(_playerIdentityFaker.Generate(1).Single()); + playerWithSingleIdentity.PlayerIdentities[0].Player = playerWithSingleIdentity; + playerWithSingleIdentity.PlayerIdentities[0].Team = team; + playerWithSingleIdentity.PlayerIdentities[0].LinkedBy = PlayerIdentityLinkedBy.DefaultIdentity; + + // player with two identities both linked by team, on the same team, not linked to member + var playerWithTwoIdentitiesLinkedByTeam = _playerFaker.Generate(1).Single(); + playerWithTwoIdentitiesLinkedByTeam.PlayerIdentities.AddRange(_playerIdentityFaker.Generate(2)); + + foreach (var identity in playerWithTwoIdentitiesLinkedByTeam.PlayerIdentities) + { + identity.Player = playerWithTwoIdentitiesLinkedByTeam; + identity.Team = team; + identity.LinkedBy = PlayerIdentityLinkedBy.Team; + } + + return [playerWithSingleIdentity, playerWithTwoIdentitiesLinkedByTeam]; + } + } +} diff --git a/Stoolball.Testing/SeedDataGenerator.cs b/Stoolball.Testing/SeedDataGenerator.cs index d3b49d73..ee6a4cd4 100644 --- a/Stoolball.Testing/SeedDataGenerator.cs +++ b/Stoolball.Testing/SeedDataGenerator.cs @@ -16,7 +16,6 @@ using Stoolball.Testing.Fakers; using Stoolball.Testing.MatchDataProviders; using Stoolball.Testing.PlayerDataProviders; -using Stoolball.Testing.TeamDataProviders; namespace Stoolball.Testing { @@ -246,9 +245,9 @@ private Team CreateTeamWithFullDetails(string teamName, Faker teamFaker) foreach (var teamInSeason in team.Seasons) { teamInSeason.Team = team; - teamInSeason.Season.Teams.Add(teamInSeason); + teamInSeason.Season!.Teams.Add(teamInSeason); } - competition.Seasons.AddRange(team.Seasons.Select(x => x.Season)); + competition.Seasons.AddRange(team.Seasons.Select(x => x.Season)!); return team; } @@ -558,7 +557,7 @@ private Tournament CreateTournamentInThePastWithFullDetailsExceptMatches(Faker - { + PlayerIdentities = + [ new PlayerIdentity { PlayerIdentityId = Guid.NewGuid(), @@ -698,7 +697,7 @@ private Player CreatePlayer(string playerName, Team team) RouteSegment = playerName.Kebaberize(), Team = team } - } + ] }; } @@ -752,16 +751,6 @@ internal TestData GenerateTestData() var poolOfTeamsWithPlayers = GenerateTeams(teamFaker); - var playerProviders = new BasePlayerDataProvider[]{ - new PlayersLinkedToMembersProvider(_teamFakerFactory, _playerFakerFactory, _playerIdentityFakerFactory), - }; - var playersFromPlayerProviders = new List(); - foreach (var provider in playerProviders) - { - var playersFromProvider = provider.CreatePlayers(testData); - playersFromPlayerProviders.AddRange(playersFromProvider); - } - // Create a pool of competitions for (var i = 0; i < 10; i++) { @@ -812,10 +801,7 @@ internal TestData GenerateTestData() testData.MatchInThePastWithFullDetailsAndTournament = FindMatchInThePastWithFullDetailsAndTournament(testData); var membersFromMatchComments = testData.Matches.SelectMany(x => x.Comments).Select(x => (memberKey: x.MemberKey, memberName: x.MemberName ?? "No name")); - var membersFromPlayerProviders = playersFromPlayerProviders.Where(p => p.MemberKey is not null).Select(p => (memberKey: p.MemberKey!.Value, memberName: "No name")); - testData.Members = membersFromMatchComments - .Union(membersFromPlayerProviders) - .Distinct(new MemberEqualityComparer()).ToList(); + testData.Members = membersFromMatchComments.ToList(); testData.Tournaments.AddRange(testData.Matches.Where(x => x.Tournament != null && !testData.Tournaments.Select(t => t.TournamentId).Contains(x.Tournament.TournamentId)).Select(x => x.Tournament).OfType()); for (var i = 0; i < 10; i++) @@ -882,13 +868,11 @@ internal TestData GenerateTestData() teamWithMatchLocation.MatchLocations.Add(testData.MatchLocationForClub); testData.MatchLocationForClub.Teams.Add(teamWithMatchLocation); - var teamsFromPlayerProviders = playersFromPlayerProviders.SelectMany(p => p.PlayerIdentities).Where(pi => pi.Team is not null).Select(pi => pi.Team).OfType(); var teamsInMatches = testData.Matches.SelectMany(x => x.Teams).Select(x => x.Team).OfType(); var teamsInTournaments = testData.Tournaments.SelectMany(x => x.Teams).Select(x => x.Team).OfType(); var teamsInSeasons = testData.Competitions.SelectMany(x => x.Seasons).SelectMany(x => x.Teams).Select(x => x.Team).OfType(); var teamsAtMatchLocations = testData.MatchLocations.SelectMany(x => x.Teams); testData.Teams = poolOfTeamsWithPlayers.Select(x => x.team) - .Union(teamsFromPlayerProviders) .Union(teamsInMatches) .Union(teamsInTournaments) .Union(teamsInSeasons) @@ -978,11 +962,8 @@ internal TestData GenerateTestData() testData.SeasonWithFullDetails = testData.Seasons.First(x => x.Teams.Any() && x.PointsRules.Any() && x.PointsAdjustments.Any()); - var playerIdentitiesInMatches = testData.Matches.SelectMany(_playerIdentityFinder.PlayerIdentitiesInMatch); - var playerIdentitiesFromPlayerProviders = playersFromPlayerProviders.SelectMany(p => p.PlayerIdentities); - testData.PlayerIdentities = playerIdentitiesInMatches - .Union(playerIdentitiesFromPlayerProviders) - .Distinct(new PlayerIdentityEqualityComparer()).ToList(); + var playerIdentitiesInMatches = testData.Matches.SelectMany(_playerIdentityFinder.PlayerIdentitiesInMatch).Distinct(new PlayerIdentityEqualityComparer()); + testData.PlayerIdentities = playerIdentitiesInMatches.ToList(); testData.Players = testData.PlayerIdentities.Select(x => x.Player).OfType().Distinct(playerComparer).ToList(); var matchProviders = new BaseMatchDataProvider[]{ @@ -1055,29 +1036,26 @@ internal TestData GenerateTestData() testData.Teams.AddRange(testData.Schools.SelectMany(x => x.Teams)); testData.MatchLocations.AddRange(testData.Schools.SelectMany(x => x.Teams).SelectMany(x => x.MatchLocations).Distinct(new MatchLocationEqualityComparer())); - foreach (var team in testData.Teams.Where(t => t.Club == null || - t.Club.Teams.Count() == 1 || - (t.Club.Teams.Count(x => !x.UntilYear.HasValue) == 1 && !t.UntilYear.HasValue))) - { - testData.TeamListings.Add(team.ToTeamListing()); - } - foreach (var club in testData.Clubs.Where(c => c.Teams.Count == 0 || - c.Teams.Count(x => !x.UntilYear.HasValue) > 1)) - { - testData.TeamListings.Add(club.ToTeamListing()); - } + CreateImmutableTestData(testData); - // Prepare collections to avoid repeatedly querying to create the same collections - testData.Awards.AddRange(testData.Matches.SelectMany(m => m.Awards)); - testData.MatchInnings.AddRange(testData.Matches.SelectMany(x => x.MatchInnings)); - testData.BowlingFigures.AddRange(testData.MatchInnings.SelectMany(mi => mi.BowlingFigures)); + BuildCollections(testData); + + EnsureCyclicalRelationshipsArePopulated(testData); + + PopulateCalculatedProperties(testData); + + return testData; + } - // This must happen after ALL scorecards and awards are finalised + ///

+ /// Ensure that calculated properties are populated. Must not change any source data or relationships. + /// + /// + private void PopulateCalculatedProperties(TestData testData) + { + // The following steps must happen after ALL scorecards and awards are finalised foreach (var identity in testData.PlayerIdentities) { - // Ensure the cyclical relationship between players and identities is populated - if (identity.Player is not null && !identity.Player.PlayerIdentities.Contains(identity)) { identity.Player.PlayerIdentities.Add(identity); } - var matchesPlayedByThisIdentity = _matchFinder.MatchesPlayedByPlayerIdentity(testData.Matches, identity.PlayerIdentityId!.Value); identity.TotalMatches = matchesPlayedByThisIdentity.Select(x => x.MatchId).Distinct().Count(); if (identity.TotalMatches > 0) @@ -1086,8 +1064,96 @@ internal TestData GenerateTestData() identity.LastPlayed = matchesPlayedByThisIdentity.Max(x => x.StartTime); } } + } - return testData; + /// + /// Ensure that objects which have cyclical relationships have those relationships populated, in case test data providers did not populate them. + /// + /// + private static void EnsureCyclicalRelationshipsArePopulated(TestData testData) + { + foreach (var identity in testData.PlayerIdentities) + { + // Ensure the cyclical relationship between players and identities is populated + if (identity.Player is not null && !identity.Player.PlayerIdentities.Contains(identity)) { identity.Player.PlayerIdentities.Add(identity); } + } + } + + /// + /// Build collections from finalised test data to avoid repeatedly querying to create the same collection. + /// + /// + private static void BuildCollections(TestData testData) + { + // Add members created to support other objects + var membersFromPlayers = testData.Players.Where(p => p.MemberKey is not null).Select(p => (memberKey: p.MemberKey!.Value, memberName: "No name")); + testData.Members = testData.Members + .Union(membersFromPlayers, new MemberEqualityComparer()) + .ToList(); + + // Add player identities created to support other objects + var playerIdentitiesFromPlayers = testData.Players.SelectMany(p => p.PlayerIdentities); + testData.PlayerIdentities = testData.PlayerIdentities + .Union(playerIdentitiesFromPlayers, new PlayerIdentityEqualityComparer()) + .ToList(); + + // Add teams created to support other objects + var teamsFromPlayers = testData.Players.SelectMany(p => p.PlayerIdentities).Where(pi => pi.Team is not null).Select(pi => pi.Team).OfType(); + testData.Teams = testData.Teams + .Union(teamsFromPlayers, new TeamEqualityComparer()) + .ToList(); + + // Create collections from team data + testData.TeamListings = CreateTeamListingsFromClubsAndTeams(testData); + + // Create collections from player data + testData.PlayersWithMultipleIdentities = FindPlayersWithMultipleIdentities(testData); + + // Create collections from match data + testData.Awards.AddRange(testData.Matches.SelectMany(m => m.Awards)); + testData.MatchInnings.AddRange(testData.Matches.SelectMany(x => x.MatchInnings)); + testData.BowlingFigures.AddRange(testData.MatchInnings.SelectMany(mi => mi.BowlingFigures)); + } + + private static List CreateTeamListingsFromClubsAndTeams(TestData testData) + { + var teamListings = new List(); + foreach (var team in testData.Teams.Where(t => t.Club == null || + t.Club.Teams.Count() == 1 || + (t.Club.Teams.Count(x => !x.UntilYear.HasValue) == 1 && !t.UntilYear.HasValue))) + { + teamListings.Add(team.ToTeamListing()); + } + foreach (var club in testData.Clubs.Where(c => c.Teams.Count == 0 || + c.Teams.Count(x => !x.UntilYear.HasValue) > 1)) + { + teamListings.Add(club.ToTeamListing()); + } + return teamListings; + } + + /// + /// Create test data from providers, for specific scenarios which must not be altered in case they are no longer valid. + /// + /// + private void CreateImmutableTestData(TestData testData) + { + testData.Players.AddRange(CreateTestDataFromPlayerProviders(testData)); + } + + private List CreateTestDataFromPlayerProviders(TestData testData) + { + var playerProviders = new BasePlayerDataProvider[]{ + new PlayersLinkedToMembersProvider(_teamFakerFactory, _playerFakerFactory, _playerIdentityFakerFactory), + new PlayersNotLinkedToMembersProvider(_teamFakerFactory, _playerFakerFactory, _playerIdentityFakerFactory) + }; + var playersFromPlayerProviders = new List(); + foreach (var provider in playerProviders) + { + var playersFromProvider = provider.CreatePlayers(testData); + playersFromPlayerProviders.AddRange(playersFromProvider); + } + return playersFromPlayerProviders; } private static List FindPlayersWithMultipleIdentities(TestData testData) @@ -1101,6 +1167,14 @@ private static List FindPlayersWithMultipleIdentities(TestData testData) results.Add(identity.Player!); } } + + foreach (var player in testData.Players.Where(p => p.PlayerIdentities.Count() > 1)) + { + if (!results.Any(x => x.PlayerId == player.PlayerId)) + { + results.Add(player); + } + } return results; } @@ -1396,14 +1470,14 @@ private static void ForceTheFifthAndSixthMostRunsResultsToBeEqual(TestData testD Player = x, Runs = testData.Matches.SelectMany(m => m.MatchInnings) .SelectMany(mi => mi.PlayerInnings) - .Where(pi => pi.Batter.Player.PlayerId == x.PlayerId) + .Where(pi => pi.Batter!.Player!.PlayerId == x.PlayerId) .Sum(pi => pi.RunsScored) }).OrderByDescending(x => x.Runs).ToList(); var differenceBetweenFifthAndSixth = allPlayers[4].Runs - allPlayers[5].Runs; var anyInningsByPlayerSix = testData.Matches.SelectMany(m => m.MatchInnings) .SelectMany(mi => mi.PlayerInnings) - .First(pi => pi.Batter.Player.PlayerId == allPlayers[5].Player.PlayerId && pi.RunsScored.HasValue); + .First(pi => pi.Batter!.Player!.PlayerId == allPlayers[5].Player.PlayerId && pi.RunsScored.HasValue); anyInningsByPlayerSix.RunsScored += differenceBetweenFifthAndSixth; } @@ -1414,7 +1488,7 @@ private void ForceTheFifthAndSixthMostWicketsResultsToBeEqual(TestData testData) Player = x, Wickets = testData.Matches.SelectMany(m => m.MatchInnings) .SelectMany(mi => mi.BowlingFigures) - .Where(pi => pi.Bowler.Player.PlayerId == x.PlayerId) + .Where(pi => pi.Bowler!.Player!.PlayerId == x.PlayerId) .Sum(pi => pi.Wickets) }).OrderByDescending(x => x.Wickets).ToList(); @@ -1424,13 +1498,13 @@ private void ForceTheFifthAndSixthMostWicketsResultsToBeEqual(TestData testData) while (differenceBetweenFifthAndSixth > 0) { var matchInningsWherePlayerSixCouldTakeWickets = testData.Matches.SelectMany(m => m.MatchInnings) - .Where(mi => sixthPlayer.Player.PlayerIdentities.Select(pi => pi.Team.TeamId!.Value).Contains(mi.BowlingTeam.Team.TeamId!.Value) && + .Where(mi => sixthPlayer.Player.PlayerIdentities.Select(pi => pi.Team!.TeamId!.Value).Contains(mi.BowlingTeam!.Team!.TeamId!.Value) && mi.PlayerInnings.Any(pi => !StatisticsConstants.DISMISSALS_THAT_ARE_OUT.Contains(pi.DismissalType)) ).First(); var playerInningsToChange = matchInningsWherePlayerSixCouldTakeWickets.PlayerInnings.First(pi => !StatisticsConstants.DISMISSALS_THAT_ARE_OUT.Contains(pi.DismissalType)); playerInningsToChange.DismissalType = DismissalType.Bowled; - playerInningsToChange.Bowler = sixthPlayer.Player.PlayerIdentities.First(x => x.Team.TeamId == matchInningsWherePlayerSixCouldTakeWickets.BowlingTeam.Team.TeamId); + playerInningsToChange.Bowler = sixthPlayer.Player.PlayerIdentities.First(x => x.Team!.TeamId == matchInningsWherePlayerSixCouldTakeWickets.BowlingTeam!.Team!.TeamId); matchInningsWherePlayerSixCouldTakeWickets.BowlingFigures = _bowlingFiguresCalculator.CalculateBowlingFigures(matchInningsWherePlayerSixCouldTakeWickets); @@ -1468,9 +1542,9 @@ internal List GenerateMatchData(TestData testData, Faker teamFaker, } var allIdentities = teamsWithIdentities.SelectMany(x => x.identities); - foreach (var player in allIdentities.Select(x => x.Player)) + foreach (var player in allIdentities.Select(x => x.Player).OfType()) { - player.PlayerIdentities = allIdentities.Where(x => x.Player?.PlayerId == player.PlayerId).ToList(); + player.PlayerIdentities = new PlayerIdentityList(allIdentities.Where(x => x.Player?.PlayerId == player.PlayerId)); } // Create matches for them to play in, with scorecards @@ -1576,12 +1650,12 @@ private Match CreateMatchWithFieldingByMultipleIdentities(TestData testData, Lis // in the first innings a fielder should take catches under multiple identities var firstInnings = match.MatchInnings[0]; - var firstInningsIdentities = firstInnings.BowlingTeam.Team.TeamId == anyTeam1.team.TeamId ? anyTeam1.identities : anyTeam2.identities; + var firstInningsIdentities = firstInnings.BowlingTeam!.Team!.TeamId == anyTeam1.team.TeamId ? anyTeam1.identities : anyTeam2.identities; - var catcherWithMultipleIdentities = firstInningsIdentities.FirstOrDefault(x => firstInningsIdentities.Count(p => p.Player.PlayerId == x.Player.PlayerId) > 1)?.Player.PlayerId; + var catcherWithMultipleIdentities = firstInningsIdentities.FirstOrDefault(x => firstInningsIdentities.Count(p => p.Player!.PlayerId == x.Player!.PlayerId) > 1)?.Player!.PlayerId; if (catcherWithMultipleIdentities.HasValue) { - var catcherIdentities = firstInningsIdentities.Where(x => x.Player.PlayerId == catcherWithMultipleIdentities).ToList(); + var catcherIdentities = firstInningsIdentities.Where(x => x.Player!.PlayerId == catcherWithMultipleIdentities).ToList(); for (var i = 0; i < 6; i++) { @@ -1601,7 +1675,7 @@ private Match CreateMatchWithFieldingByMultipleIdentities(TestData testData, Lis // in the second innings a fielder should complete run-outs under multiple identities var secondInnings = match.MatchInnings[1]; - var secondInningsIdentities = secondInnings.BowlingTeam.Team.TeamId == anyTeam1.team.TeamId ? anyTeam1.identities : anyTeam2.identities; + var secondInningsIdentities = secondInnings.BowlingTeam.Team!.TeamId == anyTeam1.team.TeamId ? anyTeam1.identities : anyTeam2.identities; var fielderWithMultipleIdentities = secondInningsIdentities.FirstOrDefault(x => secondInningsIdentities.Count(p => p.Player.PlayerId == x.Player.PlayerId) > 1)?.Player.PlayerId; if (fielderWithMultipleIdentities.HasValue) @@ -1625,18 +1699,18 @@ private Match CreateMatchWithDifferentTeamsWhereSomeonePlaysOnBothTeams(TestData // 1. Find any player with identities on two teams var anyPlayerWithIdentitiesOnMultipleTeams = teamsWithIdentities.SelectMany(x => x.identities) - .GroupBy(x => x.Player.PlayerId, x => x, (playerId, playerIdentities) => new Player { PlayerId = playerId, PlayerIdentities = playerIdentities.ToList() }) - .Where(x => x.PlayerIdentities.Select(t => t.Team.TeamId!.Value).Distinct().Count() > 1) + .GroupBy(x => x.Player!.PlayerId, x => x, (playerId, playerIdentities) => new Player { PlayerId = playerId, PlayerIdentities = new PlayerIdentityList(playerIdentities) }) + .Where(x => x.PlayerIdentities.Select(t => t.Team!.TeamId!.Value).Distinct().Count() > 1) .First(); // 2. Create a match between those teams - var teamsForPlayer = teamsWithIdentities.Where(t => anyPlayerWithIdentitiesOnMultipleTeams.PlayerIdentities.Select(x => x.Team.TeamId).Contains(t.team.TeamId)).ToList(); + var teamsForPlayer = teamsWithIdentities.Where(t => anyPlayerWithIdentitiesOnMultipleTeams.PlayerIdentities.Select(x => x.Team!.TeamId).Contains(t.team.TeamId)).ToList(); var match = _matchFactory.CreateMatchBetween(teamsForPlayer[0].team, teamsForPlayer[0].identities, teamsForPlayer[1].team, teamsForPlayer[1].identities, _randomiser.FiftyFiftyChance(), testData, nameof(CreateMatchWithDifferentTeamsWhereSomeonePlaysOnBothTeams)); // 3. We know they'll be recorded as a batter in both innings. Ensure they take a wicket too. var wicketTaken = match.MatchInnings.First().PlayerInnings.First(); wicketTaken.DismissalType = DismissalType.CaughtAndBowled; - wicketTaken.Bowler = anyPlayerWithIdentitiesOnMultipleTeams.PlayerIdentities.First(x => x.Team.TeamId == match.MatchInnings.First().BowlingTeam.Team.TeamId); + wicketTaken.Bowler = anyPlayerWithIdentitiesOnMultipleTeams.PlayerIdentities.First(x => x.Team!.TeamId == match.MatchInnings.First().BowlingTeam!.Team!.TeamId); return match; } diff --git a/Stoolball.UnitTests/Statistics/PlayerTests.cs b/Stoolball.UnitTests/Statistics/PlayerTests.cs index 53a25472..67bb9b8c 100644 --- a/Stoolball.UnitTests/Statistics/PlayerTests.cs +++ b/Stoolball.UnitTests/Statistics/PlayerTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Stoolball.Statistics; using Xunit; @@ -12,8 +11,8 @@ public void Player_name_orders_by_total_matches_aggregating_duplicates() { var player = new Player { - PlayerIdentities = new List - { + PlayerIdentities = + [ new PlayerIdentity { PlayerIdentityName = "Name One", TotalMatches = 2, @@ -29,7 +28,7 @@ public void Player_name_orders_by_total_matches_aggregating_duplicates() TotalMatches = 2, LastPlayed = DateTimeOffset.Now.Date } - } + ] }; var preferredName = player.PlayerName(); @@ -45,8 +44,8 @@ public void Player_name_prefers_most_recent_player() { var player = new Player { - PlayerIdentities = new List - { + PlayerIdentities = + [ new PlayerIdentity { PlayerIdentityName = "Name One", TotalMatches = 2, @@ -57,7 +56,7 @@ public void Player_name_prefers_most_recent_player() TotalMatches = 2, LastPlayed = DateTimeOffset.Now.Date } - } + ] }; var preferredName = player.PlayerName(); @@ -77,8 +76,8 @@ public void Player_name_prefers_complete_names(string completeName, string initi { var player = new Player { - PlayerIdentities = new List - { + PlayerIdentities = + [ new PlayerIdentity { PlayerIdentityName = "Name", TotalMatches = 2, @@ -94,7 +93,7 @@ public void Player_name_prefers_complete_names(string completeName, string initi TotalMatches = 2, LastPlayed = DateTimeOffset.Now.Date } - } + ] }; var preferredName = player.PlayerName(); @@ -111,8 +110,8 @@ public void Player_name_orders_each_group_by_total_matches_boosting_recent_playe { var player = new Player { - PlayerIdentities = new List - { + PlayerIdentities = + [ new PlayerIdentity { PlayerIdentityName = "Name One", TotalMatches = 2, @@ -188,7 +187,7 @@ public void Player_name_orders_each_group_by_total_matches_boosting_recent_playe TotalMatches = 2, LastPlayed = DateTimeOffset.Now.Date.AddDays(-4) // Fourth most recent, should boost to 2 matches * 2 weight = 4 } - } + ] }; var preferredName = player.PlayerName(); @@ -218,8 +217,8 @@ public void Player_name_prioritises_PreferredName_if_set() var player = new Player { PreferredName = "Preferred", - PlayerIdentities = new List - { + PlayerIdentities = + [ new PlayerIdentity { PlayerIdentityName = "Name Complete", // Would beat the single-word name for the preferred identity in a calculated result TotalMatches = 10, // Would beat fewer matches for the preferred identity in a calculated result @@ -235,7 +234,7 @@ public void Player_name_prioritises_PreferredName_if_set() TotalMatches = 2, LastPlayed = DateTimeOffset.Now.Date.AddYears(-1) } - } + ] }; var preferredName = player.PlayerName(); diff --git a/Stoolball.Web.UnitTests/Navigation/StatisticsBreadcrumbBuilderTests.cs b/Stoolball.Web.UnitTests/Navigation/StatisticsBreadcrumbBuilderTests.cs index b6b10e53..31355475 100644 --- a/Stoolball.Web.UnitTests/Navigation/StatisticsBreadcrumbBuilderTests.cs +++ b/Stoolball.Web.UnitTests/Navigation/StatisticsBreadcrumbBuilderTests.cs @@ -46,7 +46,7 @@ public void Player_filter_adds_player_breadcrumb() { var builder = new StatisticsBreadcrumbBuilder(); var breadcrumbs = new List(); - var player = new Player { PlayerRoute = "/players/example", PlayerIdentities = new List { new PlayerIdentity { PlayerIdentityName = "Example player" } } }; + var player = new Player { PlayerRoute = "/players/example", PlayerIdentities = [new PlayerIdentity { PlayerIdentityName = "Example player" }] }; builder.BuildBreadcrumbs(breadcrumbs, new StatisticsFilter { Player = player }); diff --git a/Stoolball.Web.UnitTests/Statistics/BaseStatisticsTableControllerTests.cs b/Stoolball.Web.UnitTests/Statistics/BaseStatisticsTableControllerTests.cs index 03a241e9..42c927f8 100644 --- a/Stoolball.Web.UnitTests/Statistics/BaseStatisticsTableControllerTests.cs +++ b/Stoolball.Web.UnitTests/Statistics/BaseStatisticsTableControllerTests.cs @@ -93,21 +93,21 @@ private static Player CreatePlayerWithIdentities() { return new Player { - PlayerIdentities = new List + PlayerIdentities = + [ + new PlayerIdentity { - new PlayerIdentity - { - PlayerIdentityName = "Example player 1", - Team = new Team { TeamId = Guid.NewGuid(), TeamName = "Team name 1" }, - TotalMatches = 5, - }, - new PlayerIdentity - { - PlayerIdentityName = "Example player 2", - Team = new Team { TeamId = Guid.NewGuid(), TeamName = "Team name 2" }, - TotalMatches = 10, // deliberately higher than the first identity to test that they're sorted later - } + PlayerIdentityName = "Example player 1", + Team = new Team { TeamId = Guid.NewGuid(), TeamName = "Team name 1" }, + TotalMatches = 5, + }, + new PlayerIdentity + { + PlayerIdentityName = "Example player 2", + Team = new Team { TeamId = Guid.NewGuid(), TeamName = "Team name 2" }, + TotalMatches = 10, // deliberately higher than the first identity to test that they're sorted later } + ] }; } diff --git a/Stoolball.Web.UnitTests/Statistics/CatchesControllerTests.cs b/Stoolball.Web.UnitTests/Statistics/CatchesControllerTests.cs index a2f2e9a7..e3accdb0 100644 --- a/Stoolball.Web.UnitTests/Statistics/CatchesControllerTests.cs +++ b/Stoolball.Web.UnitTests/Statistics/CatchesControllerTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -58,10 +57,10 @@ public async Task Player_is_swapped_to_fielder_when_filtering_results() { var player = new Player { - PlayerIdentities = new List { - new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid() }, - new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid() } - } + PlayerIdentities = [ + new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid() }, + new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid() } + ] }; var defaultFilter = new StatisticsFilter { Player = player }; var appliedFilter = defaultFilter.Clone(); diff --git a/Stoolball.Web.UnitTests/Statistics/LinkedPlayersForMemberSurfaceControllerTests.cs b/Stoolball.Web.UnitTests/Statistics/LinkedPlayersForMemberSurfaceControllerTests.cs index 6234459c..05d32b0f 100644 --- a/Stoolball.Web.UnitTests/Statistics/LinkedPlayersForMemberSurfaceControllerTests.cs +++ b/Stoolball.Web.UnitTests/Statistics/LinkedPlayersForMemberSurfaceControllerTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -48,13 +47,13 @@ private static Player CreatePlayerWith4PlayerIdentities() return new Player { PlayerRoute = "/example-player", - PlayerIdentities = new List - { + PlayerIdentities = + [ new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid(), LinkedBy = PlayerIdentityLinkedBy.Member }, new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid(), LinkedBy = PlayerIdentityLinkedBy.Member }, new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid(), LinkedBy = PlayerIdentityLinkedBy.Member }, new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid(), LinkedBy = PlayerIdentityLinkedBy.Member } - } + ] }; } diff --git a/Stoolball.Web.UnitTests/Statistics/PlayerBattingControllerTests.cs b/Stoolball.Web.UnitTests/Statistics/PlayerBattingControllerTests.cs index f8f1a86e..683696d0 100644 --- a/Stoolball.Web.UnitTests/Statistics/PlayerBattingControllerTests.cs +++ b/Stoolball.Web.UnitTests/Statistics/PlayerBattingControllerTests.cs @@ -120,13 +120,13 @@ public async Task Player_name_is_in_page_title_and_description() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamName = "Example team" } } - } + ] }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(Request.Object.QueryString.Value)).Returns(new StatisticsFilter()); _playerDataSource.Setup(x => x.ReadPlayerByRoute(Request.Object.Path, null)).Returns(Task.FromResult(player)); @@ -146,13 +146,13 @@ public async Task Player_team_is_in_page_description() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamName = "Example team" } } - } + ] }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(Request.Object.QueryString.Value)).Returns(new StatisticsFilter()); _playerDataSource.Setup(x => x.ReadPlayerByRoute(Request.Object.Path, null)).Returns(Task.FromResult(player)); @@ -171,13 +171,13 @@ public async Task Filter_team_is_passed_to_humaniser_with_name_added() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamId = Guid.NewGuid(), TeamName = "Example team" } } - } + ] }; var appliedFilterWithoutNameFromQueryString = new StatisticsFilter { Team = new Team { TeamId = player.PlayerIdentities[0].Team!.TeamId } }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(Request.Object.QueryString.Value)).Returns(appliedFilterWithoutNameFromQueryString); diff --git a/Stoolball.Web.UnitTests/Statistics/PlayerBowlingControllerTests.cs b/Stoolball.Web.UnitTests/Statistics/PlayerBowlingControllerTests.cs index da74c92d..2c39e2a4 100644 --- a/Stoolball.Web.UnitTests/Statistics/PlayerBowlingControllerTests.cs +++ b/Stoolball.Web.UnitTests/Statistics/PlayerBowlingControllerTests.cs @@ -119,13 +119,13 @@ public async Task Player_name_is_in_page_title_and_description() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamName = "Example team" } } - } + ] }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(Request.Object.QueryString.Value)).Returns(new StatisticsFilter()); _playerDataSource.Setup(x => x.ReadPlayerByRoute(Request.Object.Path, null)).Returns(Task.FromResult(player)); @@ -145,13 +145,13 @@ public async Task Player_team_is_in_page_description() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamName = "Example team" } } - } + ] }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(Request.Object.QueryString.Value)).Returns(new StatisticsFilter()); _playerDataSource.Setup(x => x.ReadPlayerByRoute(Request.Object.Path, null)).Returns(Task.FromResult(player)); @@ -170,13 +170,13 @@ public async Task Filter_team_is_passed_to_humaniser_with_name_added() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamId = Guid.NewGuid(), TeamName = "Example team" } } - } + ] }; var appliedFilterWithoutNameFromQueryString = new StatisticsFilter { Team = new Team { TeamId = player.PlayerIdentities[0].Team!.TeamId } }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(Request.Object.QueryString.Value)).Returns(appliedFilterWithoutNameFromQueryString); diff --git a/Stoolball.Web.UnitTests/Statistics/PlayerFieldingControllerTests.cs b/Stoolball.Web.UnitTests/Statistics/PlayerFieldingControllerTests.cs index 13c46528..ea7eb44e 100644 --- a/Stoolball.Web.UnitTests/Statistics/PlayerFieldingControllerTests.cs +++ b/Stoolball.Web.UnitTests/Statistics/PlayerFieldingControllerTests.cs @@ -42,15 +42,14 @@ private static Player CreatePlayer() { return new Player { - PlayerIdentities = new List - { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityId = Guid.NewGuid(), PlayerIdentityName = "Player one", Team = new Team{ TeamName = "Example team"} } - } + ] }; } @@ -175,13 +174,13 @@ public async Task Filter_team_is_passed_to_humaniser_with_name_added() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamId = Guid.NewGuid(), TeamName = "Example team" } } - } + ] }; var appliedFilterWithoutNameFromQueryString = new StatisticsFilter { Player = CreatePlayer(), Team = new Team { TeamId = player.PlayerIdentities[0].Team!.TeamId } }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(Request.Object.QueryString.Value)).Returns(appliedFilterWithoutNameFromQueryString); diff --git a/Stoolball.Web.UnitTests/Statistics/PlayerSummaryViewModelFactoryTests.cs b/Stoolball.Web.UnitTests/Statistics/PlayerSummaryViewModelFactoryTests.cs index 58c51f33..69d13a7e 100644 --- a/Stoolball.Web.UnitTests/Statistics/PlayerSummaryViewModelFactoryTests.cs +++ b/Stoolball.Web.UnitTests/Statistics/PlayerSummaryViewModelFactoryTests.cs @@ -115,13 +115,13 @@ public async Task Player_name_is_in_page_title_and_description() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamName = "Example team" } } - } + ] }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(REQUEST_QUERYSTRING)).Returns(new StatisticsFilter()); _playerDataSource.Setup(x => x.ReadPlayerByRoute(REQUEST_PATH, It.IsAny())).Returns(Task.FromResult((Player?)player)); @@ -139,13 +139,13 @@ public async Task Player_team_is_in_page_description() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamName = "Example team" } } - } + ] }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(REQUEST_QUERYSTRING)).Returns(new StatisticsFilter()); _playerDataSource.Setup(x => x.ReadPlayerByRoute(REQUEST_PATH, It.IsAny())).Returns(Task.FromResult((Player?)player)); @@ -161,13 +161,13 @@ public async Task Filter_team_is_passed_to_humaniser_with_name_added() { var player = new Player { - PlayerIdentities = new List { + PlayerIdentities = [ new PlayerIdentity { PlayerIdentityName = "Player one", Team = new Team { TeamId = Guid.NewGuid(), TeamName = "Example team" } } - } + ] }; var appliedFilterWithoutNameFromQueryString = new StatisticsFilter { Team = new Team { TeamId = player.PlayerIdentities[0].Team!.TeamId } }; _statisticsFilterQueryStringParser.Setup(x => x.ParseQueryString(REQUEST_QUERYSTRING)).Returns(appliedFilterWithoutNameFromQueryString); diff --git a/Stoolball.Web.UnitTests/Statistics/RunOutsControllerTests.cs b/Stoolball.Web.UnitTests/Statistics/RunOutsControllerTests.cs index 56000e6c..bd0186e5 100644 --- a/Stoolball.Web.UnitTests/Statistics/RunOutsControllerTests.cs +++ b/Stoolball.Web.UnitTests/Statistics/RunOutsControllerTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -58,10 +57,10 @@ public async Task Player_is_swapped_to_fielder_when_filtering_results() { var player = new Player { - PlayerIdentities = new List { - new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid() }, - new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid() } - } + PlayerIdentities = [ + new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid() }, + new PlayerIdentity{ PlayerIdentityId = Guid.NewGuid() } + ] }; var defaultFilter = new StatisticsFilter { Player = player }; var appliedFilter = defaultFilter.Clone(); diff --git a/Stoolball.Web/Teams/DeleteTeamController.cs b/Stoolball.Web/Teams/DeleteTeamController.cs index 78600979..35e94471 100644 --- a/Stoolball.Web/Teams/DeleteTeamController.cs +++ b/Stoolball.Web/Teams/DeleteTeamController.cs @@ -68,7 +68,7 @@ public DeleteTeamController(ILogger logger, model.Team.Players = (await _playerDataSource.ReadPlayerIdentities(new PlayerFilter { TeamIds = teamIds - }).ConfigureAwait(false)).Select(x => new Player { PlayerIdentities = new List { x } }).ToList(); + }).ConfigureAwait(false)).Select(x => new Player { PlayerIdentities = [x] }).ToList(); model.ConfirmDeleteRequest.RequiredText = model.Team.TeamName; diff --git a/Stoolball.Web/Teams/DeleteTeamSurfaceController.cs b/Stoolball.Web/Teams/DeleteTeamSurfaceController.cs index 4da6f4ea..14bc435c 100644 --- a/Stoolball.Web/Teams/DeleteTeamSurfaceController.cs +++ b/Stoolball.Web/Teams/DeleteTeamSurfaceController.cs @@ -95,7 +95,7 @@ public async Task DeleteTeam([Bind("RequiredText", "ConfirmationT model.Team.Players = (await _playerDataSource.ReadPlayerIdentities(new PlayerFilter { TeamIds = teamIds - })).Select(x => new Player { PlayerIdentities = new List { x } }).ToList(); + })).Select(x => new Player { PlayerIdentities = [x] }).ToList(); } model.Metadata.PageTitle = $"Delete {model.Team.TeamName}"; diff --git a/Stoolball/Statistics/Player.cs b/Stoolball/Statistics/Player.cs index c7378fd1..ce98d24b 100644 --- a/Stoolball/Statistics/Player.cs +++ b/Stoolball/Statistics/Player.cs @@ -178,7 +178,7 @@ private IEnumerable CombineDuplicateNames() public Guid? MemberKey { get; set; } - public List PlayerIdentities { get; internal set; } = new List(); + public PlayerIdentityList PlayerIdentities { get; internal set; } = new PlayerIdentityList(); public List History { get; internal set; } = new List(); diff --git a/Stoolball/Statistics/PlayerIdentityList.cs b/Stoolball/Statistics/PlayerIdentityList.cs new file mode 100644 index 00000000..d1db2cc6 --- /dev/null +++ b/Stoolball/Statistics/PlayerIdentityList.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Stoolball.Statistics +{ + public class PlayerIdentityList : List + { + public PlayerIdentityList() : base() { } + public PlayerIdentityList(IEnumerable playerIdentities) : base() + { + foreach (var identity in playerIdentities) { Add(identity); } + } + + public new void Add(PlayerIdentity playerIdentity) + { + if (playerIdentity.PlayerIdentityId.HasValue && + this.Any(pi => pi.PlayerIdentityId == playerIdentity.PlayerIdentityId)) + { + throw new InvalidOperationException($"{nameof(PlayerIdentity)} {playerIdentity.PlayerIdentityId} has already been added"); + } + base.Add(playerIdentity); + } + } +} diff --git a/Stoolball/StoolballEntityCopier.cs b/Stoolball/StoolballEntityCopier.cs index d391943e..a4c35a5f 100644 --- a/Stoolball/StoolballEntityCopier.cs +++ b/Stoolball/StoolballEntityCopier.cs @@ -41,7 +41,7 @@ public StoolballEntityCopier(IDataRedactor dataRedactor) PlayerId = player.PlayerId, PlayerRoute = player.PlayerRoute, MemberKey = player.MemberKey, - PlayerIdentities = player.PlayerIdentities.Select(x => CreateAuditableCopy(x)).OfType().ToList() + PlayerIdentities = new PlayerIdentityList(player.PlayerIdentities.Select(x => CreateAuditableCopy(x)).OfType()) }; } diff --git a/docs/Players.md b/docs/Players.md index e0dbea4a..71ef6e71 100644 --- a/docs/Players.md +++ b/docs/Players.md @@ -78,7 +78,7 @@ Stoolball England administrators can link and unlink identities, subject to the | Has a `MemberKey` and a single identity on the owned team, linked by `Member`. | Has two identities on the owned team, both linked by `Team`. | ~~Member can cancel the operation.~~ __Not allowed. See [#675](https://github.com/stoolball-england/stoolball-org-uk/issues/675).__ | ~~Has three identities, one linked by `Member`. The moved identities are linked by `Member` if they accepted, `Team` otherwise. `MemberKey` unchanged.~~ __Not allowed.__ | ~~Moved to Player A. Player B deleted.~~ __Not allowed.__ | | Has two identities on the owned team, both linked by `Team`. | On the owned team, with a single `DefaultIdentity`. || Has three identities, all linked by `Team`. | Moved to Player A. Player B deleted. | | Has two identities on the owned team, both linked by `Team`. | Has a `MemberKey` and two identities. One identity is on the owned team and one on a different team, both linked by a `Member`. |~~Member can cancel the operation.~~ __Not allowed. See [#675](https://github.com/stoolball-england/stoolball-org-uk/issues/675).__ | ~~Moved to Player B. Player A deleted.~~ __Not allowed.__ | ~~Has four identities, two linked by `Member`. The moved identities are linked by `Member` if they accepted, `Team` otherwise. `MemberKey` unchanged.~~ __Not allowed.__ | -| Has two identities on the owned team, both linked by `Team`. | Has two identities on the owned team, both linked by `Team`. || Moved to Player B. Player A deleted. | Has four identities, all linked by `Team`. `MemberKey` is `NULL`. | +| Has two identities on the owned team, both linked by `Team`. | Has two identities on the owned team, both linked by `Team`. | __Not allowed. See [#673](https://github.com/stoolball-england/stoolball-org-uk/issues/673).__ | ~~Moved to Player B. Player A deleted.~~ __Not allowed.__ | ~~Has four identities, all linked by `Team`. `MemberKey` is `NULL`.~~ __Not allowed.__ | ### Unlink an identity as a team owner