using ChatBot.Models.Configuration; using ChatBot.Models.Dto; using ChatBot.Services; using ChatBot.Services.Interfaces; using ChatBot.Tests.TestUtilities; using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using OllamaSharp.Models.Chat; namespace ChatBot.Tests.Services; public class HistoryCompressionServiceTests : UnitTestBase { private readonly Mock> _loggerMock; private readonly Mock _ollamaClientMock; private readonly AISettings _aiSettings; private readonly HistoryCompressionService _compressionService; public HistoryCompressionServiceTests() { _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); _ollamaClientMock = TestDataBuilder.Mocks.CreateOllamaClientMock(); _aiSettings = TestDataBuilder.Configurations.CreateAISettings(); var optionsMock = TestDataBuilder.Mocks.CreateOptionsMock(_aiSettings); _compressionService = new HistoryCompressionService( _loggerMock.Object, optionsMock.Object, _ollamaClientMock.Object ); } [Fact] public void ShouldCompress_ShouldReturnTrue_WhenMessageCountExceedsThreshold() { // Arrange var messageCount = 15; var threshold = 10; // Act var result = _compressionService.ShouldCompress(messageCount, threshold); // Assert result.Should().BeTrue(); } [Fact] public void ShouldCompress_ShouldReturnFalse_WhenMessageCountIsBelowThreshold() { // Arrange var messageCount = 5; var threshold = 10; // Act var result = _compressionService.ShouldCompress(messageCount, threshold); // Assert result.Should().BeFalse(); } [Fact] public void ShouldCompress_ShouldReturnFalse_WhenMessageCountEqualsThreshold() { // Arrange var messageCount = 10; var threshold = 10; // Act var result = _compressionService.ShouldCompress(messageCount, threshold); // Assert result.Should().BeFalse(); } [Fact] public async Task CompressHistoryAsync_ShouldReturnCompressedMessages_WhenSuccessful() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; var expectedResponse = "Compressed summary of previous messages"; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, expectedResponse), }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(7); // 2 compressed messages + 5 recent messages result.Should().Contain(m => m.Role == ChatRole.User && m.Content.Contains("[Сжато:")); result.Should().Contain(m => m.Role == ChatRole.Assistant && m.Content.Contains("[Сжато:")); result.Should().Contain(m => m.Role == ChatRole.User && m.Content == "User message 9"); result .Should() .Contain(m => m.Role == ChatRole.Assistant && m.Content == "Assistant response 9"); } [Fact] public async Task CompressHistoryAsync_ShouldFallbackToSimpleTrimming_WhenOllamaClientThrows() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns(ThrowAsyncEnumerable(new Exception("Ollama client error"))); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(7); // 2 compressed messages + 5 recent messages (exception is caught and handled) result.Should().Contain(m => m.Role == ChatRole.User && m.Content.Contains("[Сжато:")); result.Should().Contain(m => m.Role == ChatRole.Assistant && m.Content.Contains("[Сжато:")); result.Should().Contain(m => m.Role == ChatRole.User && m.Content == "User message 9"); result .Should() .Contain(m => m.Role == ChatRole.Assistant && m.Content == "Assistant response 9"); } [Fact] public async Task CompressHistoryAsync_ShouldReturnOriginalMessages_WhenTargetCountIsGreaterThanOrEqual() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(5); var targetCount = 10; // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().BeEquivalentTo(messages); // The service may still call AI for compression even with edge cases // So we don't verify that AI is never called } [Fact] public async Task CompressHistoryAsync_ShouldHandleEmptyMessages() { // Arrange var messages = new List(); var targetCount = 5; // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().BeEmpty(); // The service may still call AI for compression even with edge cases // So we don't verify that AI is never called } private static ThrowingAsyncEnumerable ThrowAsyncEnumerable(Exception exception) { return new ThrowingAsyncEnumerable(exception); } private class ThrowingAsyncEnumerable : IAsyncEnumerable { private readonly Exception _exception; public ThrowingAsyncEnumerable(Exception exception) { _exception = exception; } public IAsyncEnumerator GetAsyncEnumerator( CancellationToken cancellationToken = default ) { return new ThrowingAsyncEnumerator(_exception); } } private class ThrowingAsyncEnumerator : IAsyncEnumerator { private readonly Exception _exception; public ThrowingAsyncEnumerator(Exception exception) { _exception = exception; } public OllamaSharp.Models.Chat.ChatResponseStream Current => throw new InvalidOperationException(); public ValueTask DisposeAsync() { return ValueTask.CompletedTask; } public ValueTask MoveNextAsync() { throw _exception; } } [Fact] public async Task CompressHistoryAsync_ShouldHandleSystemMessagesCorrectly() { // Arrange var messages = new List { new ChatMessage { Role = ChatRole.System, Content = "System prompt" }, new ChatMessage { Role = ChatRole.User, Content = "User message 1" }, new ChatMessage { Role = ChatRole.Assistant, Content = "Assistant response 1" }, new ChatMessage { Role = ChatRole.User, Content = "User message 2" }, new ChatMessage { Role = ChatRole.Assistant, Content = "Assistant response 2" }, new ChatMessage { Role = ChatRole.User, Content = "User message 3" }, new ChatMessage { Role = ChatRole.Assistant, Content = "Assistant response 3" }, }; var targetCount = 4; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, "Compressed summary"), }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(5); result.First().Role.Should().Be(ChatRole.System); result.First().Content.Should().Be("System prompt"); } [Fact] public async Task CompressHistoryAsync_ShouldHandleOnlySystemMessages() { // Arrange var messages = new List { new ChatMessage { Role = ChatRole.System, Content = "System prompt 1" }, new ChatMessage { Role = ChatRole.System, Content = "System prompt 2" }, }; var targetCount = 1; // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(2); result.All(m => m.Role == ChatRole.System).Should().BeTrue(); // The service may still call AI for compression even with edge cases // So we don't verify that AI is never called } [Fact] public async Task CompressHistoryAsync_ShouldHandleHttpRequestException() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns(ThrowAsyncEnumerable(new HttpRequestException("Network error"))); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(7); // Should fallback to simple trimming // The service handles HTTP exceptions internally and falls back to simple trimming // So we don't expect the main warning log, but we do expect retry warning logs _loggerMock.Verify( x => x.Log( LogLevel.Warning, It.IsAny(), It.Is( (v, t) => v.ToString()!.Contains("Failed to generate AI summary") ), It.IsAny(), It.IsAny>() ), Times.AtLeastOnce ); } [Fact] public async Task CompressHistoryAsync_ShouldHandleGenericException() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns(ThrowAsyncEnumerable(new InvalidOperationException("Generic error"))); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(7); // Should fallback to simple trimming // The service handles exceptions internally and falls back to simple trimming // So we don't expect the main error log, but we do expect warning logs _loggerMock.Verify( x => x.Log( LogLevel.Warning, It.IsAny(), It.Is( (v, t) => v.ToString()!.Contains("Failed to generate AI summary") ), It.IsAny(), It.IsAny>() ), Times.AtLeastOnce ); } [Fact] public async Task CompressHistoryAsync_ShouldHandleCancellationToken() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; var cts = new CancellationTokenSource(); cts.Cancel(); // Cancel immediately _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( ThrowAsyncEnumerable(new OperationCanceledException("Operation was canceled")) ); // Act var result = await _compressionService.CompressHistoryAsync( messages, targetCount, cts.Token ); // Assert result.Should().NotBeNull(); result.Should().HaveCount(7); // Should fallback to simple trimming } [Fact] public async Task CompressHistoryAsync_ShouldLogCompressionStart() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, "Compressed summary"), }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); _loggerMock.Verify( x => x.Log( LogLevel.Information, It.IsAny(), It.Is( (v, t) => v.ToString()!.Contains("Compressing message history from") ), It.IsAny(), It.IsAny>() ), Times.Once ); } [Fact] public async Task CompressHistoryAsync_ShouldLogCompressionSuccess() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, "Compressed summary"), }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); _loggerMock.Verify( x => x.Log( LogLevel.Information, It.IsAny(), It.Is( (v, t) => v.ToString()!.Contains("Successfully compressed history") ), It.IsAny(), It.IsAny>() ), Times.Once ); } [Fact] public async Task CompressHistoryAsync_ShouldHandleVeryLongMessages() { // Arrange var longMessage = new string('A', 10000); // Very long message var messages = new List { new ChatMessage { Role = ChatRole.User, Content = longMessage }, new ChatMessage { Role = ChatRole.Assistant, Content = "Short response" }, }; var targetCount = 1; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, "Compressed summary"), }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(2); // The service compresses long messages by truncating them, not by AI summarization result.First().Content.Should().EndWith("..."); } [Fact] public async Task CompressHistoryAsync_ShouldHandleVeryShortMessages() { // Arrange var messages = new List { new ChatMessage { Role = ChatRole.User, Content = "Hi" }, new ChatMessage { Role = ChatRole.Assistant, Content = "Hello" }, new ChatMessage { Role = ChatRole.User, Content = "Bye" }, new ChatMessage { Role = ChatRole.Assistant, Content = "Goodbye" }, }; var targetCount = 2; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, "Compressed summary"), }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(2); // Short messages should be handled by simple trimming } [Fact] public async Task CompressHistoryAsync_ShouldHandleNullMessages() { // Arrange var messages = new List { new ChatMessage { Role = ChatRole.User, Content = null! }, new ChatMessage { Role = ChatRole.Assistant, Content = "Response" }, }; var targetCount = 1; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, "Compressed summary"), }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(1); } [Fact] public async Task CompressHistoryAsync_ShouldHandleEmptyContentMessages() { // Arrange var messages = new List { new ChatMessage { Role = ChatRole.User, Content = "" }, new ChatMessage { Role = ChatRole.Assistant, Content = "Response" }, }; var targetCount = 1; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, "Compressed summary"), }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(1); } [Fact] public async Task CompressHistoryAsync_ShouldHandleZeroTargetCount() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(5); var targetCount = 0; // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(2); // Should keep compressed messages // The service may still call AI for compression even with edge cases // So we don't verify that AI is never called } [Fact] public async Task CompressHistoryAsync_ShouldHandleNegativeTargetCount() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(5); var targetCount = -1; // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(2); // Should keep compressed messages // The service may still call AI for compression even with edge cases // So we don't verify that AI is never called } [Fact] public async Task CompressHistoryAsync_ShouldHandleLargeTargetCount() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(5); var targetCount = 1000; // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().BeEquivalentTo(messages); // The service may still call AI for compression even with edge cases // So we don't verify that AI is never called } [Fact] public async Task CompressHistoryAsync_ShouldHandleTimeoutException() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns(ThrowAsyncEnumerable(new OperationCanceledException("Request timeout"))); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(7); // Should fallback to simple trimming } [Fact] public async Task CompressHistoryAsync_ShouldHandleEmptyAIResponse() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, ""), // Empty response }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(7); // Should still work with fallback } [Fact] public async Task CompressHistoryAsync_ShouldHandleNullAIResponse() { // Arrange var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); var targetCount = 5; _ollamaClientMock .Setup(x => x.ChatAsync(It.IsAny())) .Returns( TestDataBuilder.Mocks.CreateAsyncEnumerable( new List { new OllamaSharp.Models.Chat.ChatResponseStream { Message = new Message(ChatRole.Assistant, null!), // Null response }, } ) ); // Act var result = await _compressionService.CompressHistoryAsync(messages, targetCount); // Assert result.Should().NotBeNull(); result.Should().HaveCount(7); // Should still work with fallback } }