Stufe 1 (#764): Agent bearbeiten, Lizenz und Einstellungen in der WebUI

- PUT /api/agents/{id} und /texts: Stammdaten, Grenzen, Budget, Identity/Soul/
  Systemprompt (L6/L7); "Jetzt ausfuehren" in der Agentenansicht (L9)
- Lizenzschluessel ueber die Weboberflaeche (L1): erst vom Lizenzserver
  bestaetigt, dann gespeichert; hebt die Sperre ohne Neustart auf
- GET/PUT /api/settings: Instanz- und Anwendungseinstellungen inkl.
  OpenRouter-Key (L3/L4/L5), Geheimnisse nur schreibend
- GET/PUT /api/verwaltung/settings: Deploymentcenter und Update-Kanal des Managers
- Einstellungsseite zeigt echte Werte statt fester Texte
- NonInteractiveLicensePrompt verwies auf --license-set-key, den es nie gab
- Sprachpakete werden bei jedem Lauf geprueft (LanguagePackTests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 13:30:20 +02:00
co-authored by Claude Opus 5.5
parent a9ce72abe2
commit 8552755bb6
20 changed files with 1677 additions and 322 deletions
@@ -0,0 +1,92 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Shouldly;
namespace ClawdDotNet.Web.Tests;
/// <summary>
/// #764 / #661: The WebUI texts come from language packs (<c>wwwroot/i18n/en.json</c> and
/// <c>de.json</c>). A key missing in one pack shows up as the bare key on screen — in the
/// language nobody tested in. So every run checks the packs, not a person after each change.
/// </summary>
public sealed partial class LanguagePackTests
{
private static readonly string WwwRoot = Path.Combine(
FindRepositoryRoot(), "src", "ClawdDotNet.Manager.Web", "wwwroot");
private static string FindRepositoryRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "ClawdDotNet.slnx")))
return dir.FullName;
}
throw new InvalidOperationException("Repository root not found above " + AppContext.BaseDirectory);
}
private static Dictionary<string, string> Pack(string language)
=> JsonSerializer.Deserialize<Dictionary<string, string>>(
File.ReadAllText(Path.Combine(WwwRoot, "i18n", language + ".json")))!;
[GeneratedRegex(@"\{(\w+)\}")]
private static partial Regex Placeholder();
// t("key" … — but not erstelleElement("div": the t must stand on its own.
[GeneratedRegex(@"(?<![\w.])t\(\s*[""']([\w.]+)[""']")]
private static partial Regex TranslateCall();
[Fact]
public void Both_packs_have_the_same_keys()
{
var en = Pack("en").Keys.ToHashSet();
var de = Pack("de").Keys.ToHashSet();
en.Except(de).ShouldBeEmpty("missing in de.json");
de.Except(en).ShouldBeEmpty("missing in en.json");
}
[Fact]
public void A_text_keeps_its_placeholders_in_both_languages()
{
// "{name} saved" in English and "Gespeichert" in German would drop the name silently.
var en = Pack("en");
var de = Pack("de");
var mismatches = en.Keys.Intersect(de.Keys)
// Shows the trigger syntax itself ("{field}" / "{feld}" stands for any field name) and
// is called without arguments — the braces are text for the reader, not placeholders.
.Where(k => k != "trigger.instructionHint")
.Where(k => !Placeholder().Matches(en[k]).Select(m => m.Value).ToHashSet()
.SetEquals(Placeholder().Matches(de[k]).Select(m => m.Value)))
.ToList();
mismatches.ShouldBeEmpty();
}
[Fact]
public void Every_key_the_code_asks_for_exists()
{
var en = Pack("en");
var missing = Directory.EnumerateFiles(WwwRoot, "*.js", SearchOption.AllDirectories)
.Where(f => !f.Contains(Path.DirectorySeparatorChar + "vendor" + Path.DirectorySeparatorChar))
.SelectMany(f => TranslateCall().Matches(File.ReadAllText(f))
.Select(m => (File: Path.GetFileName(f), Key: m.Groups[1].Value)))
.Where(x => !en.ContainsKey(x.Key))
// The example in the doc comment of i18n.js.
.Where(x => x.Key != "runs.count")
.Select(x => $"{x.File}: {x.Key}")
.Distinct()
.ToList();
missing.ShouldBeEmpty();
}
[Fact]
public void No_text_is_empty()
{
foreach (var language in new[] { "en", "de" })
Pack(language).Where(p => string.IsNullOrWhiteSpace(p.Value)).Select(p => p.Key).ShouldBeEmpty(language);
}
}