test: bUnit tests for emoji, GIF and AI chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
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));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user