using BuildFlow.Application.Chat.Commands;
using BuildFlow.Application.Chat.Handlers;
using BuildFlow.Application.Chat.Services;
using BuildFlow.Domain.Entities.ChatMessage;
using BuildFlow.Domain.Entities.Conversation;
using BuildFlow.Domain.Enums;
using BuildFlow.Infrastructure.Cache;
using BuildFlow.Infrastructure.Persistence;
using BuildFlow.Rag.Tests.Stub;
using BuildFlow.Shared.Infrastructure;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Xunit;
namespace BuildFlow.Rag.Tests.Chat;
/// 聊天生成上下文的消息数、Token 和三条生成路径复用测试。
public sealed class ChatContextWindowBuilderTests
{
[Fact]
public async Task ReturnsNewestMessagesWithinMessageLimitInChronologicalOrder()
{
await using var db = CreateSeedDb();
var conversationId = Guid.NewGuid();
SeedMessages(db, conversationId, 100);
await db.SaveChangesAsync();
var builder = CreateBuilder(db, maxMessages: 6, maxTokens: 10_000);
var result = await builder.BuildAsync(conversationId, CancellationToken.None);
result.Select(x => x.Text).Should().Equal(
"message-095",
"message-096",
"message-097",
"message-098",
"message-099",
"message-100");
}
[Fact]
public async Task StopsBeforeAddingAMessageThatWouldExceedTokenLimit()
{
await using var db = CreateSeedDb();
var conversationId = Guid.NewGuid();
AddMessage(db, conversationId, "1234", 1);
AddMessage(db, conversationId, "abcdefghij", 2);
AddMessage(db, conversationId, "12345678", 3);
await db.SaveChangesAsync();
var builder = CreateBuilder(db, maxMessages: 10, maxTokens: 5);
var result = await builder.BuildAsync(conversationId, CancellationToken.None);
result.Select(x => x.Text).Should().Equal("abcdefghij", "12345678");
}
[Fact]
public async Task SendHandlerUsesBoundedContextBuilder()
{
var setup = await CreateHandlerDbAsync("send");
await using var db = setup.Db;
var agent = new RecordingChatAgent();
var builder = CreateBuilder(db, maxMessages: 3, maxTokens: 10_000);
var handler = new SendMessageCommandHandler(
db,
agent,
new StubChatMessageCache(),
builder,
setup.UserContext);
await handler.Handle(
new SendMessageCommand(setup.ConversationId, "new-user"),
CancellationToken.None);
agent.Calls.Should().ContainSingle();
agent.Calls[0].Select(x => x.Text).Should().Equal(
"message-003",
"message-004",
"new-user");
}
[Fact]
public async Task StreamHandlerUsesBoundedContextBuilder()
{
var setup = await CreateHandlerDbAsync("stream");
await using var db = setup.Db;
var agent = new RecordingChatAgent();
var builder = CreateBuilder(db, maxMessages: 2, maxTokens: 10_000);
var handler = new StreamMessageCommandHandler(
db,
agent,
new StubChatMessageCache(),
builder,
setup.UserContext);
await CollectAsync(handler.Handle(
new StreamMessageCommand(setup.ConversationId, "stream-user"),
CancellationToken.None));
agent.Calls.Should().ContainSingle();
agent.Calls[0].Select(x => x.Text).Should().Equal("message-004", "stream-user");
}
[Fact]
public async Task EditHandlerUsesBoundedContextBuilderAfterTruncation()
{
var setup = await CreateHandlerDbAsync("edit");
await using var db = setup.Db;
var target = await db.ChatMessages.SingleAsync(x => x.Content == "message-003");
var agent = new RecordingChatAgent();
var builder = CreateBuilder(db, maxMessages: 2, maxTokens: 10_000);
var handler = new EditMessageStreamCommandHandler(
db,
agent,
new StubChatMessageCache(),
builder,
setup.UserContext);
await CollectAsync(handler.Handle(
new EditMessageStreamCommand(setup.ConversationId, target.Id, "edited-user"),
CancellationToken.None));
agent.Calls.Should().ContainSingle();
agent.Calls[0].Select(x => x.Text).Should().Equal("message-002", "edited-user");
}
private static ChatContextWindowBuilder CreateBuilder(
RagDbContext db,
int maxMessages,
int maxTokens)
=> new(
db,
Options.Create(new ChatOptions
{
MaxContextMessages = maxMessages,
MaxContextTokens = maxTokens,
CacheTtlMinutes = 60,
}));
private static RagDbContext CreateSeedDb()
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase($"chat-window-seed-{Guid.NewGuid():N}")
.Options;
return new RagDbContext(options);
}
private static async Task<(
RagDbContext Db,
Guid ConversationId,
TestCurrentUserContext UserContext)> CreateHandlerDbAsync(string name)
{
var databaseName = $"chat-window-{name}-{Guid.NewGuid():N}";
var userContext = new TestCurrentUserContext();
var conversationId = Guid.NewGuid();
await using (var seedDb = new RagDbContext(
new DbContextOptionsBuilder()
.UseInMemoryDatabase(databaseName)
.Options))
{
seedDb.Conversations.Add(new ConversationEntity
{
Id = conversationId,
Title = "window",
UserId = Guid.Parse(userContext.UserId),
CreatedBy = userContext.UserId,
UpdatedBy = userContext.UserId,
OperatorIP = userContext.IPAddress,
});
SeedMessages(seedDb, conversationId, 4);
await seedDb.SaveChangesAsync();
}
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(databaseName)
.AddInterceptors(new AuditInterceptor(userContext))
.Options;
return (
new RagDbContext(options),
conversationId,
userContext);
}
private static void SeedMessages(RagDbContext db, Guid conversationId, int count)
{
for (var index = 1; index <= count; index++)
{
AddMessage(db, conversationId, $"message-{index:000}", index);
}
}
private static void AddMessage(
RagDbContext db,
Guid conversationId,
string content,
int minute)
{
db.ChatMessages.Add(new ChatMessageEntity
{
ConversationId = conversationId,
Role = minute % 2 == 0 ? ChatRoleEnum.Assistant : ChatRoleEnum.User,
Content = content,
CreatedBy = "test",
UpdatedBy = "test",
CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMinutes(minute),
UpdatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMinutes(minute),
});
}
private static async Task> CollectAsync(IAsyncEnumerable source)
{
var result = new List();
await foreach (var item in source)
{
result.Add(item);
}
return result;
}
}