using FluentAssertions; using Koogle.Application.Games; using Koogle.Application.Games.Training; using Koogle.Domain.Enums; using Xunit; namespace Koogle.Tests.Application.Games; /// /// Unit tests for TrainingGameLogicService. /// public class TrainingGameLogicServiceTests { private readonly TrainingGameLogicService _sut; public TrainingGameLogicServiceTests() { _sut = new TrainingGameLogicService(); } #region CreateInitialModel Tests [Fact] public void CreateInitialModel_CreatesPlayerStats_ForAllPlayers() { // Arrange var playerIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; // Act var result = _sut.CreateInitialModel(playerIds, null); // Assert result.Should().BeOfType(); var model = (TrainingGameModel)result; model.PlayerStatistics.Should().HaveCount(3); model.PlayerStatistics.Keys.Should().BeEquivalentTo(playerIds); } [Fact] public void CreateInitialModel_InitializesStatsToZero() { // Arrange var playerId = Guid.NewGuid(); // Act var result = _sut.CreateInitialModel([playerId], null); // Assert var model = (TrainingGameModel)result; var stats = model.PlayerStatistics[playerId]; stats.ThrowCount.Should().Be(0); stats.PinCount.Should().Be(0); stats.CircleCount.Should().Be(0); stats.StrikeCount.Should().Be(0); stats.GutterCount.Should().Be(0); } #endregion #region ProcessThrow Tests - Pin Count [Theory] [InlineData(0)] [InlineData(1)] [InlineData(5)] [InlineData(8)] [InlineData(9)] public void ProcessThrow_UpdatesPinCount_Correctly(int pinsKnocked) { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); var afterThrow = CreateAfterThrowState(playerId, pinsKnocked); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (TrainingGameModel)updatedModel; updated.PlayerStatistics[playerId].PinCount.Should().Be(pinsKnocked); updated.PlayerStatistics[playerId].ThrowCount.Should().Be(1); } [Fact] public void ProcessThrow_AccumulatesPins_AcrossMultipleThrows() { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); // Act - 3 throws var (m1, _) = _sut.ProcessThrow(model, CreateAfterThrowState(playerId, 5)); var (m2, _) = _sut.ProcessThrow(m1, CreateAfterThrowState(playerId, 3)); var (m3, _) = _sut.ProcessThrow(m2, CreateAfterThrowState(playerId, 7)); // Assert var updated = (TrainingGameModel)m3; updated.PlayerStatistics[playerId].PinCount.Should().Be(15); updated.PlayerStatistics[playerId].ThrowCount.Should().Be(3); } #endregion #region ProcessThrow Tests - Circle Detection (Kranz) [Fact] public void ProcessThrow_DetectsCircle_WhenIsCircleTrue() { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); var afterThrow = CreateAfterThrowState(playerId, 8, isCircle: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (TrainingGameModel)updatedModel; updated.PlayerStatistics[playerId].CircleCount.Should().Be(1); } [Fact] public void ProcessThrow_AccumulatesCircles() { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); // Act - 3 circles var (m1, _) = _sut.ProcessThrow(model, CreateAfterThrowState(playerId, 8, isCircle: true)); var (m2, _) = _sut.ProcessThrow(m1, CreateAfterThrowState(playerId, 8, isCircle: true)); var (m3, _) = _sut.ProcessThrow(m2, CreateAfterThrowState(playerId, 8, isCircle: true)); // Assert var updated = (TrainingGameModel)m3; updated.PlayerStatistics[playerId].CircleCount.Should().Be(3); } #endregion #region ProcessThrow Tests - Strike Detection [Fact] public void ProcessThrow_DetectsStrike_WhenAllNinePinsKnocked() { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); var afterThrow = CreateAfterThrowState(playerId, 9, isStrike: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (TrainingGameModel)updatedModel; updated.PlayerStatistics[playerId].StrikeCount.Should().Be(1); updated.PlayerStatistics[playerId].PinCount.Should().Be(9); } #endregion #region ProcessThrow Tests - Gutter Detection (Rinne) [Fact] public void ProcessThrow_DetectsGutter_WhenNoPinsKnocked() { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); var afterThrow = CreateAfterThrowState(playerId, 0, isGutter: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (TrainingGameModel)updatedModel; updated.PlayerStatistics[playerId].GutterCount.Should().Be(1); updated.PlayerStatistics[playerId].PinCount.Should().Be(0); } #endregion #region CheckGameEnd Tests [Fact] public void CheckGameEnd_NeverEndsAutomatically() { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); // Simulate many throws for (var i = 0; i < 100; i++) { var (updated, _) = _sut.ProcessThrow(model, CreateAfterThrowState(playerId, 9, isStrike: true)); model = (TrainingGameModel)updated; } // Act var (isGameOver, winnerId) = _sut.CheckGameEnd(model); // Assert isGameOver.Should().BeFalse(); winnerId.Should().BeNull(); } #endregion #region GetPlayerStats Tests [Fact] public void GetPlayerStats_ReturnsCorrectSummary() { // Arrange var player1 = Guid.NewGuid(); var player2 = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([player1, player2], null); // Player1: 3 throws, 15 pins, 1 circle var (m1, _) = _sut.ProcessThrow(model, CreateAfterThrowState(player1, 5)); var (m2, _) = _sut.ProcessThrow(m1, CreateAfterThrowState(player1, 8, isCircle: true)); var (m3, _) = _sut.ProcessThrow(m2, CreateAfterThrowState(player1, 2)); // Player2: 2 throws, 18 pins, 1 strike var (m4, _) = _sut.ProcessThrow(m3, CreateAfterThrowState(player2, 9, isStrike: true)); var (m5, _) = _sut.ProcessThrow(m4, CreateAfterThrowState(player2, 9, isStrike: true)); // Act var stats = _sut.GetPlayerStats(m5); // Assert stats.Should().HaveCount(2); stats[player1].ThrowCount.Should().Be(3); stats[player1].PinCount.Should().Be(15); stats[player1].CircleCount.Should().Be(1); stats[player1].Average.Should().Be(5.0); stats[player2].ThrowCount.Should().Be(2); stats[player2].PinCount.Should().Be(18); stats[player2].StrikeCount.Should().Be(2); stats[player2].Average.Should().Be(9.0); } #endregion #region ValidateSetup Tests [Fact] public void ValidateSetup_AcceptsNullOptions() { // Act var result = _sut.ValidateSetup(null); // Assert result.IsValid.Should().BeTrue(); result.Errors.Should().BeEmpty(); } [Theory] [InlineData(1)] [InlineData(3)] [InlineData(5)] public void ValidateSetup_AcceptsValidThrowsPerRound(int throwsPerRound) { // Arrange var setup = new TrainingGameSetup { ThrowsPerRound = throwsPerRound }; // Act var result = _sut.ValidateSetup(setup); // Assert result.IsValid.Should().BeTrue(); } [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(6)] [InlineData(10)] public void ValidateSetup_RejectsInvalidThrowsPerRound(int throwsPerRound) { // Arrange var setup = new TrainingGameSetup { ThrowsPerRound = throwsPerRound }; // Act var result = _sut.ValidateSetup(setup); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().NotBeEmpty(); } #endregion #region GetAvailableActions Tests [Fact] public void GetAvailableActions_ReturnsEmptyList() { // Arrange var playerId = Guid.NewGuid(); var model = _sut.CreateInitialModel([playerId], null); // Act var actions = _sut.GetAvailableActions(model, playerId); // Assert actions.Should().BeEmpty(); } #endregion #region Player Rotation Tests [Fact] public void ProcessThrow_ShouldRotate_WhenRoundComplete() { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); var afterThrow = CreateAfterThrowState( playerId, 5, throwCounterPerRound: 3, throwsPerRound: 3); // Act var (_, result) = _sut.ProcessThrow(model, afterThrow); // Assert result.ShouldRotatePlayer.Should().BeTrue(); } [Fact] public void ProcessThrow_ShouldNotRotate_WhenRoundIncomplete() { // Arrange var playerId = Guid.NewGuid(); var model = (TrainingGameModel)_sut.CreateInitialModel([playerId], null); var afterThrow = CreateAfterThrowState( playerId, 5, throwCounterPerRound: 1, throwsPerRound: 3); // Act var (_, result) = _sut.ProcessThrow(model, afterThrow); // Assert result.ShouldRotatePlayer.Should().BeFalse(); } #endregion #region Helper Methods private static AfterThrowState CreateAfterThrowState( Guid playerId, int pinsKnocked, bool isCircle = false, bool isStrike = false, bool isGutter = false, bool isCleared = false, int throwCounterPerRound = 1, int throwsPerRound = 3) { // Create pin array based on what's knocked var pins = new PinStatus[9]; for (var i = 0; i < 9; i++) { pins[i] = i < pinsKnocked ? PinStatus.Fallen : PinStatus.Standing; } // Adjust for circle (8 outer pins fallen, center standing) if (isCircle) { pins = new[] { PinStatus.Fallen, PinStatus.Fallen, PinStatus.Fallen, PinStatus.Fallen, PinStatus.Standing, PinStatus.Fallen, PinStatus.Fallen, PinStatus.Fallen, PinStatus.Fallen }; } var throwPanel = new ThrowPanelSnapshot { Pins = pins, ThrowsPerRound = throwsPerRound, ThrowCounterPerRound = throwCounterPerRound, TotalThrowCounter = 1, ThrowMode = ThrowMode.Reposition, BellValue = false }; return new AfterThrowState( ThrowPanel: throwPanel, CurrentPlayerId: playerId, PinsKnocked: pinsKnocked, IsCircle: isCircle, IsStrike: isStrike, IsGutter: isGutter, IsCleared: isCleared, IsBell: false ); } #endregion }