Files
ChatBot/ChatBot.Tests/Services/HistoryCompressionServiceTests.cs
Leonid Pershin 1647fe19d3
Some checks failed
SonarQube / Build and analyze (push) Failing after 2m56s
Unit Tests / Run Tests (push) Failing after 2m28s
add more tests
2025-10-20 07:02:12 +03:00

722 lines
25 KiB
C#

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<ILogger<HistoryCompressionService>> _loggerMock;
private readonly Mock<IOllamaClient> _ollamaClientMock;
private readonly AISettings _aiSettings;
private readonly HistoryCompressionService _compressionService;
public HistoryCompressionServiceTests()
{
_loggerMock = TestDataBuilder.Mocks.CreateLoggerMock<HistoryCompressionService>();
_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<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<OllamaSharp.Models.Chat.ChatRequest>()))
.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<ChatMessage>();
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<OllamaSharp.Models.Chat.ChatResponseStream>
{
private readonly Exception _exception;
public ThrowingAsyncEnumerable(Exception exception)
{
_exception = exception;
}
public IAsyncEnumerator<OllamaSharp.Models.Chat.ChatResponseStream> GetAsyncEnumerator(
CancellationToken cancellationToken = default
)
{
return new ThrowingAsyncEnumerator(_exception);
}
}
private class ThrowingAsyncEnumerator
: IAsyncEnumerator<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<bool> MoveNextAsync()
{
throw _exception;
}
}
[Fact]
public async Task CompressHistoryAsync_ShouldHandleSystemMessagesCorrectly()
{
// Arrange
var messages = new List<ChatMessage>
{
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<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<ChatMessage>
{
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<OllamaSharp.Models.Chat.ChatRequest>()))
.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<EventId>(),
It.Is<It.IsAnyType>(
(v, t) => v.ToString()!.Contains("Failed to generate AI summary")
),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
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<OllamaSharp.Models.Chat.ChatRequest>()))
.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<EventId>(),
It.Is<It.IsAnyType>(
(v, t) => v.ToString()!.Contains("Failed to generate AI summary")
),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
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<OllamaSharp.Models.Chat.ChatRequest>()))
.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<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<EventId>(),
It.Is<It.IsAnyType>(
(v, t) => v.ToString()!.Contains("Compressing message history from")
),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
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<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<EventId>(),
It.Is<It.IsAnyType>(
(v, t) => v.ToString()!.Contains("Successfully compressed history")
),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task CompressHistoryAsync_ShouldHandleVeryLongMessages()
{
// Arrange
var longMessage = new string('A', 10000); // Very long message
var messages = new List<ChatMessage>
{
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<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<ChatMessage>
{
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<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<ChatMessage>
{
new ChatMessage { Role = ChatRole.User, Content = null! },
new ChatMessage { Role = ChatRole.Assistant, Content = "Response" },
};
var targetCount = 1;
_ollamaClientMock
.Setup(x => x.ChatAsync(It.IsAny<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<ChatMessage>
{
new ChatMessage { Role = ChatRole.User, Content = "" },
new ChatMessage { Role = ChatRole.Assistant, Content = "Response" },
};
var targetCount = 1;
_ollamaClientMock
.Setup(x => x.ChatAsync(It.IsAny<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<OllamaSharp.Models.Chat.ChatRequest>()))
.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<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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<OllamaSharp.Models.Chat.ChatRequest>()))
.Returns(
TestDataBuilder.Mocks.CreateAsyncEnumerable(
new List<OllamaSharp.Models.Chat.ChatResponseStream>
{
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
}
}