test: AI log, retention, web search adapters, prompt and UI
This commit is contained in:
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]
|
||||
|
||||
Reference in new issue
Block a user