test: AI log, retention, web search adapters, prompt and UI

This commit is contained in:
Jannis committed 2026-10-10 22:00:33 +02:00
1 parent bf5a1b9a89
commit f788f0b9d1
5 files changed
+455

No files matched your search

@@ -119,6 +119,71 @@ public sealed class AiAdapterTests
Assert.Null(response.InputTokens);
}
[Fact]
public async Task Claude_web_search_adds_the_server_tool_lifts_the_forced_tool_and_ignores_search_blocks_in_the_answer()
{
const string answer = """
{"content":[
{"type":"server_tool_use","id":"s1","name":"web_search","input":{"query":"Dinosaurier Grundschule"}},
{"type":"web_search_tool_result","tool_use_id":"s1","content":[{"type":"web_search_result","title":"Seite","url":"https://example.org"}]},
{"type":"text","text":"Ich habe recherchiert."},
{"type":"tool_use","id":"t1","name":"aufgaben","input":{"items":[{"q":"1+1"}]}}
],"usage":{"input_tokens":50,"output_tokens":20}}
""";
var (provider, handler) = Make(f => new ClaudeProvider(f), HttpStatusCode.OK, answer);
var request = new AiRequest("System", [AiMessage.User("Thema Dinosaurier")], Schema, 1000, WebSearch: true);
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Claude, "https://api.anthropic.com", "claude-sonnet-5-5", "k"), request, TestContext.Current.CancellationToken);
using var json = Json(handler.Calls[0].Body);
var tools = json.RootElement.GetProperty("tools");
Assert.Equal(2, tools.GetArrayLength());
Assert.Equal((ClaudeProvider.WebSearchToolType, "web_search", ClaudeProvider.WebSearchMaxUses),
(tools[0].GetProperty("type").GetString(), tools[0].GetProperty("name").GetString(), tools[0].GetProperty("max_uses").GetInt32()));
Assert.Equal("aufgaben", tools[1].GetProperty("name").GetString());
Assert.False(json.RootElement.TryGetProperty("tool_choice", out _)); // sonst könnte die KI nicht suchen
Assert.Contains("Werkzeug \"aufgaben\"", json.RootElement.GetProperty("system").GetString());
Assert.Equal("1+1", response.Json!.Value.GetProperty("items")[0].GetProperty("q").GetString());
Assert.Equal((50, 20), (response.InputTokens, response.OutputTokens));
}
[Fact]
public async Task Claude_without_web_search_request_never_sends_the_search_tool()
{
var (provider, handler) = Make(f => new ClaudeProvider(f), HttpStatusCode.OK, """{"content":[{"type":"tool_use","name":"aufgaben","input":{"items":[]}}]}""");
await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Claude, "https://api.anthropic.com", "m", "k"), new AiRequest("S", [AiMessage.User("x")], Schema), TestContext.Current.CancellationToken);
Assert.DoesNotContain("web_search", handler.Calls[0].Body);
using var json = Json(handler.Calls[0].Body);
Assert.Equal("tool", json.RootElement.GetProperty("tool_choice").GetProperty("type").GetString());
}
[Fact]
public async Task Claude_web_search_with_text_only_answer_falls_back_to_json_in_the_text()
{
var (provider, _) = Make(f => new ClaudeProvider(f), HttpStatusCode.OK, """{"content":[{"type":"text","text":"Hier: {\"items\":[1]}"}]}""");
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Claude, "https://api.anthropic.com", "m", "k"),
new AiRequest("S", [AiMessage.User("x")], Schema, WebSearch: true), TestContext.Current.CancellationToken);
Assert.Equal(1, response.Json!.Value.GetProperty("items").GetArrayLength());
}
[Fact]
public async Task Ollama_and_openai_compatible_requests_ignore_the_web_search_flag()
{
var (ollama, ollamaHandler) = Make(f => new OllamaProvider(f), HttpStatusCode.OK, """{"message":{"content":"ok"}}""");
var (openAi, openAiHandler) = Make(f => new OpenAiCompatibleProvider(f), HttpStatusCode.OK, """{"choices":[{"message":{"content":"ok"}}]}""");
var request = AiRequest.Simple("Hi") with { WebSearch = true };
await ollama.CompleteAsync(new AiProviderSettings(AiProviderType.Ollama, "http://localhost:11434", "m", null), request, TestContext.Current.CancellationToken);
await openAi.CompleteAsync(new AiProviderSettings(AiProviderType.OpenAiCompatible, "https://api.perplexity.ai", "sonar", "k"), request, TestContext.Current.CancellationToken);
Assert.DoesNotContain("search", ollamaHandler.Calls[0].Body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("search", openAiHandler.Calls[0].Body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task OpenAI_compatible_uses_chat_completions_bearer_data_urls_and_json_schema()
{
@@ -0,0 +1,173 @@
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Ai;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace FamilyPlanner.UnitTests.Ai;
/// <summary>KI-Protokoll (Phase 12b): was gespeichert wird, Prompt-Beschreibung, Aufbewahrung, Validierung der Einstellungen und der Online-Suche.</summary>
public sealed class AiExchangeLogTests : IDisposable
{
private static readonly DateTimeOffset Now = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly SqliteConnection _connection = new("DataSource=:memory:");
public AiExchangeLogTests()
{
_connection.Open();
using var db = CreateContext();
db.Database.EnsureCreated();
}
public void Dispose() => _connection.Dispose();
private AppDbContext CreateContext() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(_connection).ReplaceService<IModelCustomizer, NoRowVersionCustomizer>().Options);
/// <summary>SQLite kennt weder <c>rowversion</c> noch Vergleiche mit <see cref="DateTimeOffset"/>: Zeitpunkte werden als Zahl gespeichert, Versionsspalten gewöhnlich.</summary>
private sealed class NoRowVersionCustomizer(ModelCustomizerDependencies dependencies) : ModelCustomizer(dependencies)
{
public override void Customize(ModelBuilder modelBuilder, DbContext context)
{
base.Customize(modelBuilder, context);
foreach (var entity in modelBuilder.Model.GetEntityTypes().Where(e => !e.IsOwned() && e.FindPrimaryKey() is not null && e.ClrType.Namespace?.StartsWith("FamilyPlanner", StringComparison.Ordinal) == true).ToList())
{
foreach (var property in entity.GetProperties().Where(p => p.ClrType == typeof(DateTimeOffset) || p.ClrType == typeof(DateTimeOffset?)).ToList())
{
modelBuilder.Entity(entity.ClrType).Property(property.Name).HasConversion(new DateTimeOffsetToBinaryConverter());
}
foreach (var property in entity.GetProperties().Where(p => p.ClrType == typeof(byte[]) && p.IsConcurrencyToken).ToList())
{
modelBuilder.Entity(entity.ClrType).Property(property.Name).IsConcurrencyToken(false).ValueGeneratedNever();
}
}
}
}
// ---------- Was wird gespeichert? ----------
[Theory]
[InlineData(AiPurpose.Default, false, true)]
[InlineData(AiPurpose.LearningGeneration, false, true)]
[InlineData(AiPurpose.LearningImageGrading, false, true)]
[InlineData(AiPurpose.ChatAssistant, false, false)]
[InlineData(AiPurpose.ChatAssistant, true, true)]
public void Chat_content_is_only_stored_with_the_explicit_admin_switch(AiPurpose purpose, bool chatSwitch, bool expected) =>
Assert.Equal(expected, AiExchangeRules.StoresContent(purpose, chatSwitch));
[Fact]
public void Prompt_description_has_system_then_messages_and_counts_images_without_storing_them()
{
var request = new AiRequest("Sei Lehrer.", [AiMessage.User("Was siehst du?", new AiImage("image/png", [1, 2, 3])), new AiMessage("assistant", "Eine Katze.")]);
var text = AiExchangeRules.DescribePrompt(request);
Assert.StartsWith("[System]\nSei Lehrer.".ReplaceLineEndings(), text.ReplaceLineEndings());
Assert.Contains("[Nutzer]", text);
Assert.Contains("[KI]", text);
Assert.Contains("(+ 1 Bild(er), nicht gespeichert)", text);
Assert.DoesNotContain("AQID", text); // Base64 der Bytes
Assert.DoesNotContain("Online-Suche", text);
}
[Fact]
public void Prompt_description_marks_web_search_requests() =>
Assert.StartsWith("(Online-Suche erlaubt)", AiExchangeRules.DescribePrompt(AiRequest.Simple("Hi") with { WebSearch = true }));
[Fact]
public void Long_texts_are_truncated_with_a_marker_and_short_texts_stay_untouched()
{
Assert.Null(AiExchange.Truncate(null, 10));
Assert.Equal("kurz", AiExchange.Truncate("kurz", 10));
var cut = AiExchange.Truncate(new string('x', 50), 10)!;
Assert.StartsWith(new string('x', 10), cut);
Assert.EndsWith("(gekürzt)", cut);
}
// ---------- Aufbewahrung ----------
private async Task SeedAsync(params DateTimeOffset[] startedAt)
{
await using var db = CreateContext();
foreach (var at in startedAt)
{
db.AiExchanges.Add(new AiExchange { ProviderName = "P", Model = "m", StartedAt = at, Prompt = "p", ContentStored = true });
}
await db.SaveChangesAsync(Ct);
}
private async Task<int> CountAsync()
{
await using var db = CreateContext();
return await db.AiExchanges.CountAsync(Ct);
}
[Fact]
public async Task Entries_older_than_the_retention_period_are_deleted_and_newer_ones_stay()
{
await SeedAsync(Now.AddDays(-91), Now.AddDays(-90).AddMinutes(-1), Now.AddDays(-89), Now.AddHours(-1));
await using var db = CreateContext();
var purged = await AiExchangeRetentionService.PurgeAsync(db, Now, 90, Ct);
Assert.Equal(2, purged);
Assert.Equal(2, await CountAsync());
Assert.Equal(0, await AiExchangeRetentionService.PurgeAsync(db, Now, 90, Ct)); // idempotent
}
[Theory]
[InlineData(0)]
[InlineData(-5)]
public async Task Zero_retention_keeps_everything(int days)
{
await SeedAsync(Now.AddDays(-4000), Now);
await using var db = CreateContext();
Assert.Equal(0, await AiExchangeRetentionService.PurgeAsync(db, Now, days, Ct));
Assert.Equal(2, await CountAsync());
}
// ---------- Einstellungen und Validierung ----------
private static FamilySettingsDto Settings(int retention = 90, bool chat = false) =>
new("Familie", "Europe/Berlin", 7, ["Mittagessen"], ["Haushalt"], true, false, false, false, 30, false, false, retention, chat);
[Theory]
[InlineData(0, true)]
[InlineData(90, true)]
[InlineData(AiRules.MaxLogRetentionDays, true)]
[InlineData(-1, false)]
[InlineData(AiRules.MaxLogRetentionDays + 1, false)]
public void Log_retention_must_be_between_zero_and_the_maximum(int days, bool valid) =>
Assert.Equal(valid, new FamilySettingsDtoValidator().Validate(Settings(days)).IsValid);
[Fact]
public void Defaults_keep_ninety_days_and_do_not_log_chat_content()
{
var settings = FamilyPlanner.Domain.Settings.FamilySettings.CreateDefault();
Assert.Equal((AiRules.DefaultLogRetentionDays, false), (settings.AiLogRetentionDays, settings.AiLogChatContent));
Assert.False(new AiProviderConfig().AllowWebSearch);
Assert.False(AiRequest.Simple("x").WebSearch);
}
[Theory]
[InlineData(AiProviderType.Claude, true)]
[InlineData(AiProviderType.Ollama, false)]
[InlineData(AiProviderType.OpenAiCompatible, false)]
public void Only_claude_offers_the_web_search_switch(AiProviderType type, bool supported)
{
Assert.Equal(supported, AiRules.SupportsWebSearch(type));
var request = new SaveAiProviderRequest("Name", type, null, "modell", "key", false, 60, true, false, AiPurpose.None, 50, null, AllowWebSearch: true);
Assert.Equal(supported, new SaveAiProviderRequestValidator().Validate(request).IsValid);
Assert.True(new SaveAiProviderRequestValidator().Validate(request with { AllowWebSearch = false }).IsValid);
}
}
@@ -42,6 +42,27 @@ public sealed class LearningAiDraftTests
Assert.NotNull(ai.Schema);
}
[Fact]
public void Web_search_is_off_by_default_and_the_prompt_treats_web_content_as_untrusted_when_on()
{
var off = LearningAiPrompt.Build(Request(extra: "Dinosaurier"));
Assert.False(off.WebSearch);
Assert.DoesNotContain("Web", off.System);
Assert.DoesNotContain("Online", LearningAiPrompt.StoredPrompt(Request()));
const string injection = "SECRET-MARKER";
var request = Request(extra: injection) with { WebSearch = true };
var on = LearningAiPrompt.Build(request);
Assert.True(on.WebSearch);
Assert.Contains("nicht vertrauenswürdig", on.System);
Assert.Contains("ignorierst", on.System);
Assert.DoesNotContain(injection, on.System); // der Freitext bleibt in der abgegrenzten Nutzernachricht
Assert.Contains("\"\"\"", Assert.Single(on.Messages).Text);
Assert.NotNull(on.Schema);
Assert.Contains("Online-Recherche", LearningAiPrompt.StoredPrompt(request));
}
[Fact]
public void Math_prompts_ask_for_an_expression_german_prompts_do_not()
{
@@ -1,4 +1,5 @@
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Ai;
using FamilyPlanner.Web.Client.Components.Admin;
using FamilyPlanner.Web.Client.Services;
@@ -91,6 +92,55 @@ internal sealed class FakeAiApi : IAiApi
public Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiUsageOverviewDto>.Ok(Usage));
// ---- KI-Protokoll (Phase 12b) ----
public List<AiExchangeDetailDto> Exchanges { get; } = [];
public List<AiExchangeFilter> ExchangeFilters { get; } = [];
public List<Guid> DeletedExchanges { get; } = [];
public bool Cleared { get; private set; }
public AiExchangeDetailDto SeedExchange(string person, AiPurpose purpose = AiPurpose.LearningGeneration, bool success = true, bool contentStored = true,
string? prompt = "PROMPT-TEXT", string? response = "ANTWORT-TEXT", bool webSearch = false)
{
var dto = new AiExchangeDetailDto(Guid.NewGuid(), new DateTimeOffset(2026, 10, 12, 8, 0, 0, TimeSpan.Zero).AddMinutes(Exchanges.Count), person, purpose, "Ollama", "llama3.1",
850, success, success ? null : "Timeout", contentStored, webSearch, 0, 10, 20, contentStored ? prompt : null, contentStored ? response : null);
Exchanges.Add(dto);
return dto;
}
public Task<ApiResult<PagedResult<AiExchangeListItemDto>>> GetExchangesAsync(AiExchangeFilter filter, CancellationToken ct = default)
{
ExchangeFilters.Add(filter);
var items = Exchanges
.Where(e => (filter.Purpose is null || e.Purpose == filter.Purpose) && (filter.Success is null || e.Success == filter.Success))
.OrderByDescending(e => e.At)
.Select(e => new AiExchangeListItemDto(e.Id, e.At, e.UserName, e.Purpose, e.ProviderName, e.Model, e.DurationMs, e.Success, e.Error, e.ContentStored, e.WebSearch, e.ImageCount))
.ToList();
return Task.FromResult(ApiResult<PagedResult<AiExchangeListItemDto>>.Ok(new PagedResult<AiExchangeListItemDto>(items, items.Count, filter.Page, filter.PageSize)));
}
public Task<ApiResult<AiExchangeDetailDto>> GetExchangeAsync(Guid id, CancellationToken ct = default) =>
Task.FromResult(ApiResult<AiExchangeDetailDto>.Ok(Exchanges.Single(e => e.Id == id)));
public Task<ApiResult<List<AiExchangePersonDto>>> GetExchangePeopleAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<AiExchangePersonDto>>.Ok(Exchanges.Select(e => e.UserName).Distinct().Select(n => new AiExchangePersonDto(Guid.NewGuid(), n)).ToList()));
public Task<ApiResult<bool>> DeleteExchangeAsync(Guid id, CancellationToken ct = default)
{
DeletedExchanges.Add(id);
Exchanges.RemoveAll(e => e.Id == id);
return Task.FromResult(ApiResult<bool>.Ok(true));
}
public Task<ApiResult<bool>> ClearExchangesAsync(CancellationToken ct = default)
{
Cleared = true;
Exchanges.Clear();
return Task.FromResult(ApiResult<bool>.Ok(true));
}
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default)
{
StatusCalls++;
@@ -280,4 +330,110 @@ public sealed class AiManagementTests : BunitContext
cut.WaitForAssertion(() => Assert.Contains("Noch kein Anbieter", cut.Markup));
Assert.Equal([provider.Id], _api.Deleted);
}
// ---------- KI-Protokoll (Phase 12b) ----------
[Fact]
public void Log_lists_calls_and_marks_entries_without_content()
{
_api.SeedExchange("Mama", AiPurpose.LearningGeneration, webSearch: true);
_api.SeedExchange("Papa", AiPurpose.ChatAssistant, contentStored: false);
_api.SeedExchange("Mama", AiPurpose.Default, success: false);
var cut = Render();
Assert.Equal(3, cut.FindAll(".fp-ai-log tbody tr").Count);
Assert.Contains("Online-Suche", cut.Find(".fp-ai-log").TextContent);
Assert.Single(cut.FindAll(".fp-ai-log-nocontent"));
Assert.Contains("Fehler", cut.Find(".fp-ai-log .fp-ai-failed").TextContent);
Assert.DoesNotContain("PROMPT-TEXT", cut.Markup); // Liste ohne Inhalt
}
[Fact]
public void Log_detail_shows_prompt_on_top_and_answer_below_as_plain_text()
{
_api.SeedExchange("Mama", prompt: "<b>PROMPT</b> Zeile", response: "<script>alert(1)</script> ANTWORT");
var cut = Render();
cut.Find(".fp-ai-log-open").Click();
cut.WaitForElement(".fp-ai-prompt");
Assert.Equal("<b>PROMPT</b> Zeile", cut.Find(".fp-ai-prompt").TextContent);
Assert.Contains("<script>alert(1)</script> ANTWORT", cut.Find(".fp-ai-response").TextContent);
Assert.Empty(cut.FindAll(".fp-ai-response script"));
Assert.True(cut.Markup.IndexOf("fp-ai-prompt", StringComparison.Ordinal) < cut.Markup.IndexOf("fp-ai-response", StringComparison.Ordinal));
}
[Fact]
public void Log_detail_of_a_private_chat_call_explains_that_no_content_was_stored()
{
_api.SeedExchange("Papa", AiPurpose.ChatAssistant, contentStored: false);
var cut = Render();
cut.Find(".fp-ai-log-open").Click();
cut.WaitForElement(".fp-ai-exchange-nocontent");
Assert.Empty(cut.FindAll(".fp-ai-prompt"));
}
[Fact]
public void Log_entries_can_be_deleted_one_by_one_and_all_at_once_after_confirmation()
{
var older = _api.SeedExchange("Mama");
_api.SeedExchange("Papa"); // neuere Einträge stehen oben
var cut = Render();
cut.FindAll(".fp-ai-log-delete")[1].Click();
cut.WaitForElement(".mud-message-box");
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Abbrechen")).Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-message-box")));
Assert.Empty(_api.DeletedExchanges);
cut.FindAll(".fp-ai-log-delete")[1].Click();
cut.WaitForElement(".mud-message-box");
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForAssertion(() => Assert.Equal([older.Id], _api.DeletedExchanges));
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-ai-log tbody tr")));
cut.Find(".fp-ai-log-clear").Click();
cut.WaitForElement(".mud-message-box");
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Alles löschen")).Click();
cut.WaitForAssertion(() => Assert.True(_api.Cleared));
cut.WaitForAssertion(() => Assert.Contains("Keine Einträge", cut.Find(".fp-ai-log-empty").TextContent));
}
// ---------- Online-Suche im Anbieter-Dialog ----------
[Fact]
public void Web_search_switch_is_not_offered_for_ollama_which_says_it_is_unsupported()
{
var cut = Render();
cut.Find(".fp-add-ai").Click();
cut.WaitForElement("#ai-provider-form");
Assert.Empty(cut.FindAll(".fp-ai-websearch"));
Assert.Contains("von Ollama nicht unterstützt", cut.Find(".fp-ai-websearch-unsupported").TextContent);
}
[Fact]
public void Claude_provider_saves_the_web_search_opt_in_and_defaults_to_off()
{
_api.Seed("Claude", AiProviderType.Claude, "https://api.anthropic.com", hasKey: true);
var cut = Render();
cut.Find("button[aria-label='Claude bearbeiten']").Click();
cut.WaitForElement(".fp-ai-websearch");
Assert.Contains("verlassen", cut.Find(".fp-ai-websearch-hint").TextContent);
cut.Find("#ai-provider-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Saved));
Assert.False(_api.Saved[0].AllowWebSearch);
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("#ai-provider-form")));
cut.Find("button[aria-label='Claude bearbeiten']").Click();
cut.WaitForElement(".fp-ai-websearch");
cut.Find(".fp-ai-websearch input").Change(true);
cut.Find("#ai-provider-form").Submit();
cut.WaitForAssertion(() => Assert.Equal(2, _api.Saved.Count));
Assert.True(_api.Saved[1].AllowWebSearch);
}
}
@@ -78,6 +78,46 @@ public sealed class AiDraftTests : LearningTestBase
cut.WaitForAssertion(() => Assert.Contains("Mit KI erstellen", cut.Find(".fp-learning-ai").TextContent));
}
// ---------- Online-Suche (Phase 12b) ----------
private IRenderedComponent<LearningHost> OpenDialogWithSearch(bool available)
{
AiApi.Status = new AiStatusDto(true, true, false, true, available);
var cut = OpenOverview();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-learning-ai")));
cut.Find(".fp-learning-ai").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-create")));
return cut;
}
[Fact]
public void Web_search_checkbox_is_hidden_unless_the_provider_supports_it_and_an_admin_allowed_it()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenDialogWithSearch(available: false);
Assert.Empty(cut.FindAll(".fp-ai-websearch"));
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.Single(Api.AiDrafts));
Assert.False(Api.AiDrafts[0].WebSearch);
}
[Fact]
public void Web_search_is_off_by_default_shows_the_privacy_hint_and_is_sent_only_when_ticked()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenDialogWithSearch(available: true);
Assert.Contains("Online recherchieren", cut.Find(".fp-ai-websearch").TextContent);
Assert.Contains("Suchbegriffe", cut.Find(".fp-ai-websearch-hint").TextContent);
Assert.Contains("Heimnetz", cut.Find(".fp-ai-websearch-hint").TextContent);
cut.Find(".fp-ai-websearch input").Change(true);
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.Single(Api.AiDrafts));
Assert.True(Api.AiDrafts[0].WebSearch);
}
// ---------- Dialog ----------
[Fact]