test: bUnit tests for emoji, GIF and AI chat

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 18:57:57 +02:00
1 parent 66b5f4e751
commit 154e400d13
3 files changed
+222 -1

No files matched your search

@@ -178,7 +178,7 @@
<MudIcon Icon="@Icons.Material.Filled.AddPhotoAlternate" />
</label>
}
<button type="button" class="fp-chat-tool fp-chat-emoji" title="Emoji einfügen" aria-label="Emoji einfügen" aria-expanded="@(_emojiOpen ? "true" : "false")"
<button type="button" class="fp-chat-tool fp-chat-emoji-button" title="Emoji einfügen" aria-label="Emoji einfügen" aria-expanded="@(_emojiOpen ? "true" : "false")"
disabled="@_sending" @onclick="() => _emojiOpen = !_emojiOpen">
<MudIcon Icon="@Icons.Material.Filled.InsertEmoticon" />
</button>
@@ -0,0 +1,174 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Tests.Shopping;
using FamilyPlanner.Shared.Chat;
using FamilyPlanner.Web.Client.Components.Chat;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Chat;
/// <summary>Emoji-Auswahl, GIF-Suche und KI-Chat im Chat (ADR-0022).</summary>
public sealed class ChatExtrasTests : BunitContext
{
private readonly FakeChatApi _api = new();
public ChatExtrasTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
JSInterop.Setup<bool>("fpChat.isVisible", _ => true).SetResult(true);
Services.AddMudServices();
Services.AddSingleton<IChatApi>(_api);
Services.AddSingleton<ILiveConnection>(new FakeLiveConnection());
Services.AddSingleton<IAuthService, NoopAuthService>();
Services.AddSingleton<ApiAuthenticationStateProvider>();
Services.AddSingleton<TimeProvider>(new MutableTimeProvider(FakeChatApi.Now));
Services.AddSingleton<ChatStore>();
var permissions = PermissionMatrix.Effective([Roles.Family]);
Services.GetRequiredService<ApiAuthenticationStateProvider>().SetUser(new CurrentUserDto(
FakeChatApi.Me, "ich", "Ich", null, "#1E88E5", [Roles.Family], false, null, null, permissions));
}
private IRenderedComponent<ChatPageHost> Open(Guid? channelId = null)
{
var cut = Render<ChatPageHost>(p => p.Add(x => x.ChannelId, channelId));
if (channelId is not null)
{
cut.WaitForElement("form.fp-chat-composer");
}
return cut;
}
[Fact]
public void Emoji_picker_opens_inserts_at_the_end_and_offers_categories()
{
var cut = Open(FakeChatApi.FamilyId);
Assert.Empty(cut.FindAll(".fp-emoji-picker"));
cut.Find("#chat-input").Input("Hallo ");
cut.Find("button.fp-chat-emoji-button").Click();
Assert.Equal(EmojiCatalog.Categories.Count, cut.FindAll(".fp-emoji-tab").Count);
cut.Find("button.fp-emoji-item[data-emoji='😀']").Click();
cut.Find("button.fp-emoji-item[data-emoji='😀']").Click();
cut.WaitForAssertion(() => Assert.Equal("Hallo 😀😀", cut.Find("#chat-input").GetAttribute("value")));
cut.Find("button.fp-emoji-tab[data-category='food']").Click();
Assert.NotEmpty(cut.FindAll("button.fp-emoji-item[data-emoji='🍕']"));
Assert.Empty(cut.FindAll("button.fp-emoji-item[data-emoji='😀']"));
}
[Fact]
public void Emoji_is_not_inserted_when_the_message_would_become_too_long()
{
var cut = Open(FakeChatApi.FamilyId);
var full = new string('a', FamilyPlanner.Domain.Chat.ChatMessage.TextMaxLength);
cut.Find("#chat-input").Input(full);
cut.Find("button.fp-chat-emoji-button").Click();
cut.Find("button.fp-emoji-item[data-emoji='😀']").Click();
Assert.Equal(full, cut.Find("#chat-input").GetAttribute("value"));
}
[Fact]
public void Gif_button_is_hidden_without_a_server_key()
{
var cut = Open(FakeChatApi.FamilyId);
Assert.Empty(cut.FindAll("button.fp-chat-gif"));
Assert.NotEmpty(cut.FindAll("button.fp-chat-emoji-button"));
}
[Fact]
public void Gif_button_is_visible_with_a_server_key()
{
_api.Features = new ChatFeaturesDto(true, false, false);
var cut = Open(FakeChatApi.FamilyId);
cut.WaitForElement("button.fp-chat-gif");
}
[Fact]
public void Picking_a_gif_sends_only_its_id_and_shows_the_message()
{
_api.Features = new ChatFeaturesDto(true, false, false);
var cut = Open(FakeChatApi.FamilyId);
cut.WaitForElement("button.fp-chat-gif").Click();
cut.WaitForElement("#gif-query").Input("katze");
cut.Find("button.fp-gif-search").Click();
cut.WaitForElement("button.fp-gif-item[data-gif-id='abc123']").Click();
cut.WaitForAssertion(() => Assert.Equal((FakeChatApi.FamilyId, "abc123"), _api.SentGifs.Single()));
Assert.Equal("katze", _api.GifQueries.Single());
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-chat-msg img")));
}
[Fact]
public void Ai_chat_button_starts_the_own_channel_and_the_channel_has_no_image_or_gif_buttons()
{
_api.Features = new ChatFeaturesDto(true, true, true);
var cut = Open();
cut.WaitForElement("button.fp-chat-new-ai").Click();
cut.WaitForAssertion(() => Assert.EndsWith($"chat/{FakeChatApi.AiChannelId}", Services.GetRequiredService<Microsoft.AspNetCore.Components.NavigationManager>().Uri));
Assert.Equal(1, _api.AiStarts);
}
[Fact]
public void Ai_channel_has_no_image_or_gif_buttons_but_keeps_emoji()
{
_api.AddAi();
_api.Features = new ChatFeaturesDto(true, true, true);
var cut = Open(FakeChatApi.AiChannelId);
Assert.Empty(cut.FindAll(".fp-chat-attach"));
Assert.Empty(cut.FindAll("button.fp-chat-gif"));
Assert.NotEmpty(cut.FindAll("button.fp-chat-emoji-button"));
}
[Fact]
public void Ai_chat_button_is_hidden_when_unavailable()
{
var unavailable = Open();
unavailable.WaitForElement(".fp-chat-list-pane");
Assert.Empty(unavailable.FindAll("button.fp-chat-new-ai"));
}
[Fact]
public void Ai_chat_button_is_hidden_when_the_channel_already_exists()
{
_api.AddAi();
_api.Features = new ChatFeaturesDto(false, true, false);
var started = Open();
started.WaitForElement(".fp-chat-channel");
Assert.Empty(started.FindAll("button.fp-chat-new-ai"));
Assert.Contains("KI-Chat", started.Markup);
}
[Fact]
public void Ai_failure_keeps_the_question_and_shows_the_plain_message_without_keeping_the_draft()
{
_api.AddAi();
_api.AiError = "Die KI ist gerade nicht erreichbar. Versuch es später noch einmal.";
var cut = Open(FakeChatApi.AiChannelId);
cut.Find("#chat-input").Input("Wie hoch ist der Mount Everest?");
cut.Find("form.fp-chat-composer").Submit();
cut.WaitForAssertion(() => Assert.Contains("nicht erreichbar", cut.Markup));
cut.WaitForAssertion(() => Assert.Equal(string.Empty, cut.Find("#chat-input").GetAttribute("value") ?? string.Empty));
}
private sealed class NoopAuthService : IAuthService
{
public Task<CurrentUserDto?> GetCurrentUserAsync(CancellationToken cancellationToken = default) => Task.FromResult<CurrentUserDto?>(null);
public Task<AuthResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task LogoutAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<AuthResult> ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}
@@ -34,6 +34,7 @@ internal sealed class FakeChatApi : IChatApi
new(Mia, "Mia", "#E53935", null, Roles.Child),
new(Ben, "Ben", "#43A047", null, Roles.Family),
new(Gast, "Oma", "#8E24AA", null, Roles.Guest),
new(ChatRules.AiSenderId, ChatRules.AiName, "#9E9E9E", null, Roles.Guest),
];
public bool Offline { get; set; }
@@ -52,6 +53,26 @@ internal sealed class FakeChatApi : IChatApi
public string? SendError { get; set; }
/// <summary>Wie ein Server, bei dem die KI nicht antworten konnte: Frage gespeichert, Antwort fehlt (Problem-Titel „KI-Chat“).</summary>
public string? AiError { get; set; }
public ChatFeaturesDto Features { get; set; } = new(false, false, false);
public List<string> GifQueries { get; } = [];
public List<(Guid Channel, string GifId)> SentGifs { get; } = [];
public int AiStarts { get; private set; }
public static readonly Guid AiChannelId = Guid.Parse("00000000-0000-0000-0000-0000000000c3");
public ChatChannelDto AddAi()
{
var channel = new ChatChannelDto(AiChannelId, ChatChannelKind.Ai, "KI-Chat", null, true, 0, null, null);
Channels.Add(channel);
return channel;
}
public ChatChannelDto AddDirect(Guid partner, string title, int unread = 0, bool canSend = true)
{
var channel = new ChatChannelDto(partner == Mia ? MiaDirectId : Guid.NewGuid(), ChatChannelKind.Direct, title, partner, canSend, unread, null, null);
@@ -95,6 +116,26 @@ internal sealed class FakeChatApi : IChatApi
public Task<ApiResult<ChatChannelDto>> StartDirectAsync(Guid userId, CancellationToken ct = default) =>
Result(() => Channels.FirstOrDefault(c => c.PartnerId == userId) ?? AddDirect(userId, Users.Single(u => u.Id == userId).DisplayName));
public Task<ApiResult<ChatFeaturesDto>> GetFeaturesAsync(CancellationToken ct = default) => Result(() => Features);
public Task<ApiResult<ChatChannelDto>> StartAiAsync(CancellationToken ct = default)
{
AiStarts++;
return Result(() => Channels.FirstOrDefault(c => c.Kind == ChatChannelKind.Ai) ?? AddAi());
}
public Task<ApiResult<GifSearchResultDto>> SearchGifsAsync(string query, CancellationToken ct = default)
{
GifQueries.Add(query);
return Result(() => new GifSearchResultDto([new GifResultDto("abc123", "Katze", "/api/chat/gifs/abc123/preview")]));
}
public Task<ApiResult<ChatMessageDto>> SendGifAsync(Guid channelId, SendGifRequest request, CancellationToken ct = default)
{
SentGifs.Add((channelId, request.GifId));
return Result(() => Store(channelId, request.Id, request.Text, $"/api/chat/attachments/{request.Id}"));
}
public Task<ApiResult<ChatMessagePageDto>> GetMessagesAsync(Guid channelId, Guid? before, int take, CancellationToken ct = default)
{
MessageCalls++;
@@ -119,6 +160,12 @@ internal sealed class FakeChatApi : IChatApi
}
Sent.Add((channelId, request.Text));
if (AiError is not null)
{
Store(channelId, request.Id, request.Text, null);
return Task.FromResult(ApiResult<ChatMessageDto>.Fail(new ApiProblem { Status = 502, Title = ChatStore.AiChatProblemTitle, Detail = AiError }));
}
return Result(() => Store(channelId, request.Id, request.Text, null));
}