Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft. Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch. WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b umbenannten Parameter angeglichen. Dabei behoben: - 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue" gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day. - Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine CSS-Klasse gab - jetzt "success" wie laufende Instanzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
15 KiB
C#
406 lines
15 KiB
C#
using ClawdDotNet.App.Services;
|
|
using ClawdDotNet.Core.Config;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.App.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// The rules that until 2026-09-06 lived in the Avalonia view models.
|
|
///
|
|
/// <para>That they lived there was the reason for these tests: a view model has
|
|
/// no test coverage — the UI guide says so explicitly —, and with the
|
|
/// second user interface the same rules would have been written a second time. Now
|
|
/// they exist once, and from here on it is checked that both user interfaces get the same
|
|
/// thing.</para>
|
|
/// </summary>
|
|
public sealed class AgentAdministrationTests
|
|
{
|
|
[Fact]
|
|
public void Eine_doppelte_Agenten_ID_wird_abgelehnt()
|
|
{
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
var ergebnis = host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
|
|
|
ergebnis.Ok.ShouldBeFalse();
|
|
ergebnis.Message.ShouldContain("existiert bereits");
|
|
host.Host.Instance.Agents.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Die_Pruefung_ignoriert_Gross_und_Kleinschreibung()
|
|
{
|
|
// Two agents differing only in that would be two folders on the target platform
|
|
// and the same agent to every human.
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.AgentAdmin.Add(new NewAgent("SCOUT", "Scout", "modell")).Ok.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Ohne_ID_wird_kein_Agent_angelegt()
|
|
{
|
|
using var host = new TestHost();
|
|
|
|
host.Host.AgentAdmin.Add(new NewAgent(" ", "Scout", "modell")).Ok.ShouldBeFalse();
|
|
host.Host.Instance.Agents.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_angelegter_Agent_ueberlebt_den_Neustart()
|
|
{
|
|
// The core of the finding: LoadInstanceConfig clears the agent list and rebuilds it
|
|
// from the folders Agents/Agent-*. An agent that only stood in
|
|
// InstanceSettings.json was gone after the next start — exactly what
|
|
// the user interface did until 2026-09-06.
|
|
using var host = new TestHost();
|
|
|
|
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell", "Späher")).Ok.ShouldBeTrue();
|
|
|
|
var nachNeustart = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
|
|
|
|
nachNeustart.Agents.ShouldContain(a => a.AgentId == "scout" && a.DisplayName == "Scout");
|
|
}
|
|
|
|
[Fact]
|
|
public void Aenderungen_an_einem_Agenten_ueberleben_den_Neustart()
|
|
{
|
|
// The same cause from the other direction: until 2026-09-06 nobody called
|
|
// SaveAgentSettings during operation. Model, prompt and budget only stood in
|
|
// InstanceSettings.json — it looked like saving and wasn't.
|
|
using var host = new TestHost();
|
|
|
|
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "altes-modell"));
|
|
|
|
var agent = host.Host.AgentAdmin.Find("scout")!;
|
|
agent.Model = "neues-modell";
|
|
agent.Budget.DailyTokens = 12_345;
|
|
agent.Identity = "# Identity: geändert";
|
|
|
|
host.Host.AgentAdmin.Save();
|
|
|
|
var nachNeustart = host.Host.Directories
|
|
.LoadInstanceConfig(host.Host.InstancePath).Agents
|
|
.Single(a => a.AgentId == "scout");
|
|
|
|
nachNeustart.Model.ShouldBe("neues-modell");
|
|
nachNeustart.Budget.DailyTokens.ShouldBe(12_345);
|
|
nachNeustart.Identity.ShouldBe("# Identity: geändert");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_Werkzeug_bleibt_ueber_den_Neustart_zugelassen()
|
|
{
|
|
using var host = new TestHost();
|
|
|
|
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
|
host.Host.AgentAdmin.SetToolEnabled("scout", "FileRW", enabled: true);
|
|
|
|
host.Host.Directories
|
|
.LoadInstanceConfig(host.Host.InstancePath).Agents
|
|
.Single(a => a.AgentId == "scout")
|
|
.Tools.ShouldContainKey("FileRW");
|
|
}
|
|
|
|
[Fact]
|
|
public void Jede_Aenderung_meldet_sich()
|
|
{
|
|
// Without this notification an agent created in the web UI would not appear in the
|
|
// running window — the whole reason the event exists.
|
|
using var host = new TestHost();
|
|
|
|
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
|
|
|
host.Changes.ShouldHaveSingleItem();
|
|
host.Changes[0].Kind.ShouldBe(InstanceChangeKind.Agents);
|
|
host.Changes[0].Id.ShouldBe("scout");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_entfernter_Agent_kommt_nach_dem_Neustart_nicht_wieder()
|
|
{
|
|
// The opposite direction of the same finding: merely striking it from the list was not
|
|
// enough — the folder stayed behind, and at the next start the agent was
|
|
// back.
|
|
using var host = new TestHost();
|
|
|
|
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
|
host.Host.AgentAdmin.Remove("scout").Ok.ShouldBeTrue();
|
|
|
|
host.Host.Directories
|
|
.LoadInstanceConfig(host.Host.InstancePath).Agents
|
|
.ShouldNotContain(a => a.AgentId == "scout");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_entfernter_Agent_behaelt_seine_Arbeit()
|
|
{
|
|
// Renamed, not deleted. Its work is in the working folder, and a click
|
|
// in a list is no decision to throw it away.
|
|
using var host = new TestHost();
|
|
|
|
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
|
|
|
var arbeitsdatei = Path.Combine(
|
|
host.Host.AgentAdmin.Find("scout")!.AgentDir, "Workspace", "ergebnis.txt");
|
|
|
|
File.WriteAllText(arbeitsdatei, "wichtig");
|
|
|
|
host.Host.AgentAdmin.Remove("scout").Ok.ShouldBeTrue();
|
|
|
|
var agentsDir = Path.Combine(host.Host.InstancePath, "Agents");
|
|
|
|
Directory.GetDirectories(agentsDir, "Entfernt-*").ShouldHaveSingleItem();
|
|
|
|
Directory.GetFiles(agentsDir, "ergebnis.txt", SearchOption.AllDirectories)
|
|
.ShouldHaveSingleItem();
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_Werkzeug_zulassen_legt_eine_leere_Konfiguration_an()
|
|
{
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.AgentAdmin.SetToolEnabled("scout", "FileRW", enabled: true).Ok.ShouldBeTrue();
|
|
|
|
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldContainKey("FileRW");
|
|
host.Host.AgentAdmin.Find("scout")!.Tools["FileRW"].ShouldBeEmpty();
|
|
}
|
|
|
|
// ─── #641: permissions in the tool configuration ───
|
|
|
|
[Fact]
|
|
public void Rechte_werden_normalisiert_gespeichert_und_ueberleben_den_Neustart()
|
|
{
|
|
using var host = new TestHost();
|
|
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
|
host.Host.AgentAdmin.SetToolEnabled("scout", "Database", enabled: true);
|
|
|
|
host.Host.AgentAdmin.SetToolRights("scout", "Database", ["SELECT", "Insert", "drop"], ["drop"]).Ok.ShouldBeTrue();
|
|
|
|
var rechte = ClawdDotNet.Core.Staging.Werkzeugrechte.Aus(
|
|
host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath).Agents
|
|
.Single(a => a.AgentId == "scout").Tools["Database"].AsReadOnly());
|
|
rechte.Erlaubt.ShouldNotBeNull().OrderBy(x => x).ToArray().ShouldBe(["insert", "select"], "verboten schlaegt erlaubt");
|
|
rechte.IstVerboten("DROP").ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Einstellungen_speichern_loescht_die_Rechte_nicht()
|
|
{
|
|
// The settings forms write a fresh dictionary without "erlaubt".
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
host.Host.AgentAdmin.SetToolEnabled("scout", "Database", enabled: true);
|
|
host.Host.AgentAdmin.SetToolRights("scout", "Database", ["select"], []);
|
|
|
|
host.Host.AgentAdmin.SetToolSettings("scout", "Database", new Dictionary<string, object?> { ["type"] = "mysql" });
|
|
|
|
host.Host.AgentAdmin.Find("scout")!.Tools["Database"].ShouldContainKey("erlaubt");
|
|
}
|
|
|
|
// ─── #660: prompt history retention ───
|
|
|
|
[Fact]
|
|
public void Prompt_history_retention_is_stored_per_agent_and_for_the_instance()
|
|
{
|
|
using var host = new TestHost();
|
|
host.Host.AgentAdmin.Add(new NewAgent("tresor", "Tresor", "modell"));
|
|
|
|
host.Host.AgentAdmin.SetPromptHistoryRetention("tresor", 0).Ok.ShouldBeTrue();
|
|
host.Host.AgentAdmin.SetDefaultPromptHistoryRetention(30).Ok.ShouldBeTrue();
|
|
|
|
var reloaded = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
|
|
reloaded.PromptHistory.RetentionDays.ShouldBe(30);
|
|
reloaded.Agents.Single(a => a.AgentId == "tresor").PromptHistory.RetentionDays.ShouldBe(0);
|
|
|
|
host.Host.AgentAdmin.SetPromptHistoryRetention("tresor", null).Ok.ShouldBeTrue();
|
|
host.Host.AgentAdmin.Find("tresor")!.PromptHistory.RetentionDays.ShouldBeNull("back to the instance default");
|
|
}
|
|
|
|
[Fact]
|
|
public void Rechte_nur_fuer_zugewiesene_Werkzeuge()
|
|
{
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.AgentAdmin.SetToolRights("scout", "Database", ["select"], []).Ok.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_Werkzeug_wegnehmen_entfernt_auch_seine_Einstellungen()
|
|
{
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.AgentAdmin.SetToolSettings(
|
|
"scout", "Mail", new Dictionary<string, object?> { ["host"] = "mail.example" });
|
|
|
|
host.Host.AgentAdmin.SetToolEnabled("scout", "Mail", enabled: false);
|
|
|
|
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldNotContainKey("Mail");
|
|
}
|
|
|
|
[Fact]
|
|
public void Einstellungen_zu_schreiben_heisst_das_Werkzeug_zu_wollen()
|
|
{
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.AgentAdmin.SetToolSettings(
|
|
"scout", "Mail", new Dictionary<string, object?> { ["host"] = "mail.example" });
|
|
|
|
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldContainKey("Mail");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_unbekannter_Agent_liefert_eine_Antwort_und_keine_Ausnahme()
|
|
{
|
|
using var host = new TestHost();
|
|
|
|
host.Host.AgentAdmin.Remove("gibtsnicht").Ok.ShouldBeFalse();
|
|
host.Host.AgentAdmin.SetToolEnabled("gibtsnicht", "FileRW", true).Ok.ShouldBeFalse();
|
|
host.Changes.ShouldBeEmpty();
|
|
}
|
|
}
|
|
|
|
public sealed class JobAdministrationTests
|
|
{
|
|
[Fact]
|
|
public void Ein_unlesbarer_Zeitplan_wird_abgelehnt()
|
|
{
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
var ergebnis = host.Host.JobAdmin.Add(new JobDefinition(
|
|
"scout", JobKind.AgentWakeup, Cron: "jeden zweiten Dienstag"));
|
|
|
|
ergebnis.Ok.ShouldBeFalse();
|
|
ergebnis.Message.ShouldContain("nicht lesbar");
|
|
host.Host.AgentAdmin.Find("scout")!.Scheduler.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_leerer_Zeitplan_ist_kein_Fehler()
|
|
{
|
|
// Empty means "never" and is a valid statement. Only unreadable is an error.
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup)).Ok.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_Wakeup_laesst_sich_nicht_abschalten()
|
|
{
|
|
// SchedulerConfig has no switch, and inventing one here would mean that
|
|
// the scanner would first have to read it. Otherwise a "disabled"
|
|
// wakeup would happily keep running.
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
|
|
|
|
var ergebnis = host.Host.JobAdmin.SetEnabled(
|
|
"scout", JobAdministration.WakeupId("scout"), enabled: false);
|
|
|
|
ergebnis.Ok.ShouldBeFalse();
|
|
ergebnis.Message.ShouldContain("Nur Tool-Jobs");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_Tool_Job_laesst_sich_abschalten_ohne_ihn_zu_verlieren()
|
|
{
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.JobAdmin.Add(new JobDefinition(
|
|
"scout", JobKind.ToolJob, ToolName: "Mail", JobTypeId: "PollJob", Cron: "0 * * * *"));
|
|
|
|
var job = host.Host.JobAdmin.List().Single();
|
|
|
|
host.Host.JobAdmin.SetEnabled("scout", job.JobId, enabled: false).Ok.ShouldBeTrue();
|
|
|
|
var danach = host.Host.JobAdmin.List().Single();
|
|
danach.Enabled.ShouldBeFalse();
|
|
danach.ToolName.ShouldBe("Mail");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ohne_Scanner_gilt_ein_Wakeup_als_nicht_aktiv()
|
|
{
|
|
// It runs as long as the scanner runs — that is the only periodic
|
|
// driver. Without it, it stands still without anyone having disabled it.
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
|
|
|
|
host.Host.Scanner.ShouldBeNull();
|
|
host.Host.JobAdmin.List().Single().Enabled.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Der_naechste_Lauf_wird_aus_dem_Zeitplan_gerechnet()
|
|
{
|
|
var (next, valid) = JobAdministration.NextRun("0 * * * *");
|
|
|
|
valid.ShouldBeTrue();
|
|
next.ShouldNotBeNull();
|
|
next!.Value.ShouldBeGreaterThan(DateTime.UtcNow);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_unlesbarer_Zeitplan_ist_von_einem_fehlenden_zu_unterscheiden()
|
|
{
|
|
JobAdministration.NextRun("").ShouldBe((null, true));
|
|
JobAdministration.NextRun("kaputt").ShouldBe((null, false));
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_entfernter_Wakeup_ist_weg_und_der_Agent_bleibt()
|
|
{
|
|
using var host = new TestHost(TestHost.Agent("scout"));
|
|
|
|
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
|
|
|
|
host.Host.JobAdmin.Remove("scout", JobAdministration.WakeupId("scout"), JobKind.AgentWakeup)
|
|
.Ok.ShouldBeTrue();
|
|
|
|
host.Host.AgentAdmin.Find("scout").ShouldNotBeNull();
|
|
host.Host.JobAdmin.List().ShouldBeEmpty();
|
|
}
|
|
}
|
|
|
|
public sealed class ServiceAdministrationTests
|
|
{
|
|
[Fact]
|
|
public void Die_eingebauten_Dienste_kommen_genau_einmal_dazu()
|
|
{
|
|
using var host = new TestHost();
|
|
|
|
host.Host.ServiceAdmin.EnsureBuiltIns();
|
|
var nachDemErsten = host.Host.Instance.Services.Count;
|
|
|
|
host.Host.ServiceAdmin.EnsureBuiltIns();
|
|
|
|
nachDemErsten.ShouldBe(BuiltInServices.CreateDefaults().Count);
|
|
host.Host.Instance.Services.Count.ShouldBe(nachDemErsten);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_abgeschalteter_eingebauter_Dienst_bleibt_abgeschaltet()
|
|
{
|
|
using var host = new TestHost();
|
|
|
|
host.Host.ServiceAdmin.EnsureBuiltIns();
|
|
|
|
var dienst = host.Host.Instance.Services.First(s => s.BuiltIn);
|
|
host.Host.ServiceAdmin.SetEnabled(dienst.ServiceId, enabled: false);
|
|
|
|
host.Host.ServiceAdmin.EnsureBuiltIns();
|
|
|
|
host.Host.ServiceAdmin.Find(dienst.ServiceId)!.Enabled.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_Dienst_ohne_Namen_wird_abgelehnt()
|
|
{
|
|
using var host = new TestHost();
|
|
|
|
host.Host.ServiceAdmin.Add(new NewService(" ", "HTTP", 8080)).Ok.ShouldBeFalse();
|
|
host.Host.Instance.Services.ShouldBeEmpty();
|
|
}
|
|
}
|