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:
co-authored by
Claude Opus 5.5
parent
a9ce72abe2
commit
8552755bb6
@@ -0,0 +1,101 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #764: Editing an agent from the WebUI — what the desktop agent page did directly on the
|
||||
/// configuration objects, now one checked call.
|
||||
/// </summary>
|
||||
public sealed class AgentUpdateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Changes_survive_a_restart_including_identity_and_soul()
|
||||
{
|
||||
// Identity and Soul live in their own files; written only into AgentSettings.json they
|
||||
// would be overwritten by the next load.
|
||||
using var host = new TestHost();
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "old-model")).Ok.ShouldBeTrue();
|
||||
|
||||
var result = host.Host.AgentAdmin.Update("scout", new AgentUpdate(
|
||||
DisplayName: "Scout II", Model: "new-model", SystemPrompt: "Be brief.",
|
||||
Identity: "# Identity: changed", Soul: "# Soul: changed", PromptCaching: "OFF",
|
||||
MaxSteps: 42, DailyTokens: 12_345, DailyCostUsd: 1.5m, WhenExhausted: "catchupOnce"));
|
||||
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
|
||||
var reloaded = host.Host.Directories
|
||||
.LoadInstanceConfig(host.Host.InstancePath).Agents
|
||||
.Single(a => a.AgentId == "scout");
|
||||
|
||||
reloaded.DisplayName.ShouldBe("Scout II");
|
||||
reloaded.Model.ShouldBe("new-model");
|
||||
reloaded.SystemPrompt.ShouldBe("Be brief.");
|
||||
reloaded.Identity.ShouldBe("# Identity: changed");
|
||||
reloaded.Soul.ShouldBe("# Soul: changed");
|
||||
reloaded.PromptCaching.ShouldBe("off");
|
||||
reloaded.LoopGuard.MaxSteps.ShouldBe(42);
|
||||
reloaded.Budget.DailyTokens.ShouldBe(12_345);
|
||||
reloaded.Budget.DailyCostUsd.ShouldBe(1.5m);
|
||||
reloaded.Budget.WhenExhausted.ShouldBe("catchUpOnce");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fields_left_out_keep_their_value()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "model", "Watches the market")).Ok.ShouldBeTrue();
|
||||
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Identity: "keep me")).Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Model: "other")).Ok.ShouldBeTrue();
|
||||
|
||||
var agent = host.Host.AgentAdmin.Find("scout")!;
|
||||
agent.Model.ShouldBe("other");
|
||||
agent.Description.ShouldBe("Watches the market");
|
||||
agent.Identity.ShouldBe("keep me");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("display")]
|
||||
[InlineData("caching")]
|
||||
[InlineData("exhausted")]
|
||||
[InlineData("steps")]
|
||||
[InlineData("budget")]
|
||||
public void One_bad_value_changes_nothing(string bad)
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout", "Scout"));
|
||||
var update = bad switch
|
||||
{
|
||||
"display" => new AgentUpdate(Model: "new-model", DisplayName: " "),
|
||||
"caching" => new AgentUpdate(Model: "new-model", PromptCaching: "sometimes"),
|
||||
"exhausted" => new AgentUpdate(Model: "new-model", WhenExhausted: "panic"),
|
||||
"steps" => new AgentUpdate(Model: "new-model", MaxSteps: 0),
|
||||
_ => new AgentUpdate(Model: "new-model", DailyCostUsd: -1m)
|
||||
};
|
||||
|
||||
host.Host.AgentAdmin.Update("scout", update).Ok.ShouldBeFalse();
|
||||
|
||||
host.Host.AgentAdmin.Find("scout")!.Model.ShouldBe("anthropic/claude-sonnet-4-5");
|
||||
host.Changes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_agent_is_refused()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Update("ghost", new AgentUpdate(Model: "x")).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_is_announced()
|
||||
{
|
||||
// Without the event the other views (and a second browser) would keep the old values.
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Model: "x")).Ok.ShouldBeTrue();
|
||||
|
||||
host.Changes.ShouldHaveSingleItem().Kind.ShouldBe(InstanceChangeKind.Agents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.App.Settings;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #764: The settings the WebUI reads and writes — what the desktop settings page edited.
|
||||
/// </summary>
|
||||
public sealed class SettingsAdministrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_snapshot_never_carries_a_secret()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.Instance.OpenRouterApiKey = "sk-or-secret";
|
||||
host.Host.Settings.AppSettings.DeploymentcenterToken = "dc-secret";
|
||||
|
||||
var snapshot = host.Host.SettingsAdmin.Snapshot();
|
||||
|
||||
snapshot.OpenRouterApiKeySet.ShouldBeTrue();
|
||||
snapshot.DeploymentcenterTokenSet.ShouldBeTrue();
|
||||
System.Text.Json.JsonSerializer.Serialize(snapshot).ShouldNotContain("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instance_settings_survive_a_restart()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.Directories.SaveInstanceConfig(host.Host.InstancePath, host.Host.Instance);
|
||||
|
||||
var result = host.Host.SettingsAdmin.Update(new SettingsUpdate(
|
||||
InstanceName: "Renamed", AutostartByManager: true, DailyCostUsd: 2.5m, DailyTokens: 900,
|
||||
AutoBackupEnabled: true, AutoBackupTime: "4:30", BackupKeepCount: 7));
|
||||
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
|
||||
var reloaded = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
|
||||
reloaded.InstanceName.ShouldBe("Renamed");
|
||||
reloaded.AutostartByManager.ShouldBeTrue();
|
||||
reloaded.Budget.DailyCostUsd.ShouldBe(2.5m);
|
||||
reloaded.Budget.DailyTokens.ShouldBe(900);
|
||||
reloaded.AutoBackupEnabled.ShouldBeTrue();
|
||||
reloaded.AutoBackupTime.ShouldBe("04:30");
|
||||
reloaded.BackupKeepCount.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_secret_keeps_the_one_there_is()
|
||||
{
|
||||
// The form never gets the key back, so it sends an empty field. That must not wipe it.
|
||||
using var host = new TestHost();
|
||||
host.Host.Instance.OpenRouterApiKey = "sk-or-keep";
|
||||
host.Host.Settings.AppSettings.DeploymentcenterToken = "dc-keep";
|
||||
|
||||
host.Host.SettingsAdmin.Update(new SettingsUpdate(OpenRouterApiKey: "", DeploymentcenterToken: " ")).Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.Instance.OpenRouterApiKey.ShouldBe("sk-or-keep");
|
||||
host.Host.Settings.AppSettings.DeploymentcenterToken.ShouldBe("dc-keep");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_key_says_it_needs_a_restart()
|
||||
{
|
||||
// The engine is built at start; without saying so the save button would seem to do nothing.
|
||||
using var host = new TestHost();
|
||||
|
||||
var result = host.Host.SettingsAdmin.Update(new SettingsUpdate(OpenRouterApiKey: "sk-or-new"));
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Message.ShouldContain("restart");
|
||||
host.Host.Instance.OpenRouterApiKey.ShouldBe("sk-or-new");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Application_settings_are_written_to_settings_json()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.SettingsAdmin.Update(new SettingsUpdate(
|
||||
MinimumLogLevel: "warn", UpdateChannel: "beta", ErrorReportingEnabled: false)).Ok.ShouldBeTrue();
|
||||
|
||||
var root = Path.GetDirectoryName(host.Host.Settings.SettingsPath)!;
|
||||
var reloaded = new SettingsManager(root);
|
||||
reloaded.Load();
|
||||
reloaded.AppSettings.MinimumLogLevel.ShouldBe("Warn");
|
||||
reloaded.AppSettings.UpdateChannel.ShouldBe("beta");
|
||||
reloaded.AppSettings.ErrorReportingEnabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("time")]
|
||||
[InlineData("level")]
|
||||
[InlineData("url")]
|
||||
[InlineData("channel")]
|
||||
[InlineData("name")]
|
||||
[InlineData("keep")]
|
||||
public void One_bad_value_changes_nothing(string bad)
|
||||
{
|
||||
using var host = new TestHost();
|
||||
var update = bad switch
|
||||
{
|
||||
"time" => new SettingsUpdate(DailyTokens: 5, AutoBackupTime: "25:00"),
|
||||
"level" => new SettingsUpdate(DailyTokens: 5, MinimumLogLevel: "Chatty"),
|
||||
"url" => new SettingsUpdate(DailyTokens: 5, OpenRouterBaseUrl: "file:///etc/passwd"),
|
||||
"channel" => new SettingsUpdate(DailyTokens: 5, UpdateChannel: "nightly"),
|
||||
"name" => new SettingsUpdate(DailyTokens: 5, InstanceName: " "),
|
||||
_ => new SettingsUpdate(DailyTokens: 5, BackupKeepCount: -1)
|
||||
};
|
||||
|
||||
host.Host.SettingsAdmin.Update(update).Ok.ShouldBeFalse();
|
||||
|
||||
host.Host.Instance.Budget.DailyTokens.ShouldBe(0);
|
||||
host.Changes.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using ClawdDotNet.Licensing;
|
||||
using Deploymentcenter.Client;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #764: The license key entered in the web interface — the only way in for a manager that
|
||||
/// runs as a service and never asks (<see cref="NonInteractiveLicensePrompt"/>).
|
||||
///
|
||||
/// <para>The license server is replaced by given answers. The promise checked here is not
|
||||
/// "the server judges correctly" — that is the SDK's job — but "only a confirmed key is
|
||||
/// stored, and a confirmed key lifts the lock without a restart".</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseKeyEntryTests : IAsyncDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(), "clawd-tests", "lic-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
private readonly FakeLauncher _launcher = new();
|
||||
private readonly FakeChannel _channel = new();
|
||||
private readonly InstanceDirectoryManager _directories;
|
||||
|
||||
/// <summary>What the "server" says, per key.</summary>
|
||||
private readonly Dictionary<string, LicenseValidationResult> _answers = new();
|
||||
|
||||
private static readonly SupervisionOptions Options = new(
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300),
|
||||
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
||||
MaxDrainWait: TimeSpan.FromSeconds(2));
|
||||
|
||||
public LicenseKeyEntryTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
_channel.Launcher = _launcher;
|
||||
|
||||
_answers["GOOD-KEY"] = new LicenseValidationResult { IsValid = true, Status = "valid" };
|
||||
_answers["REVOKED-KEY"] = new LicenseValidationResult { IsValid = false, Status = "revoked" };
|
||||
_answers["OFFLINE-KEY"] = new LicenseValidationResult { IsValid = false, Status = "server_unavailable", IsTransient = true };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
private ManagerCore Manager()
|
||||
{
|
||||
var store = new ManagerSettingsStore(_root);
|
||||
store.Load();
|
||||
|
||||
var gate = new LicenseGate(
|
||||
new ManagerLicenseStore(store), NullLogger.Instance,
|
||||
new NonInteractiveLicensePrompt(_ => { }), "0.0.0-test",
|
||||
(key, _) => Task.FromResult(_answers.TryGetValue(key, out var answer)
|
||||
? answer
|
||||
: new LicenseValidationResult { IsValid = false, Status = "not_found" }));
|
||||
|
||||
var registry = new InstanceRegistry(
|
||||
_directories, _launcher, _channel, NullLoggerFactory.Instance, Options);
|
||||
|
||||
return new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance, license: gate);
|
||||
}
|
||||
|
||||
private string StoredKey()
|
||||
{
|
||||
var store = new ManagerSettingsStore(_root);
|
||||
return store.Load().LicenseKey;
|
||||
}
|
||||
|
||||
private void AutostartInstance(string name)
|
||||
{
|
||||
var path = _directories.CreateInstance(name);
|
||||
var file = Path.Combine(path, "InstanceSettings.json");
|
||||
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(file))!;
|
||||
config.AutostartByManager = true;
|
||||
File.WriteAllText(file, JsonSerializer.Serialize(config));
|
||||
_channel.AnswerFor[Path.GetFullPath(path)] = new InstanceIdentity("i-" + name, name, "0.1.2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_confirmed_key_is_stored_and_lifts_the_lock_at_once()
|
||||
{
|
||||
AutostartInstance("Alpha");
|
||||
|
||||
await using var manager = Manager();
|
||||
(await manager.StartAsync()).ShouldBeFalse(); // no key yet: locked
|
||||
_launcher.Starts.ShouldBe(0);
|
||||
|
||||
var result = await manager.ApplyLicenseKeyAsync(" GOOD-KEY ");
|
||||
|
||||
result.Applied.ShouldBeTrue(result.Message);
|
||||
manager.LicenseHolds.ShouldBeTrue();
|
||||
StoredKey().ShouldBe("GOOD-KEY");
|
||||
_launcher.Starts.ShouldBe(1); // the autostart instance came up
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_key_leaves_the_current_one_in_place()
|
||||
{
|
||||
await using (var first = Manager())
|
||||
(await first.ApplyLicenseKeyAsync("GOOD-KEY")).Applied.ShouldBeTrue();
|
||||
|
||||
await using var manager = Manager();
|
||||
var result = await manager.ApplyLicenseKeyAsync("REVOKED-KEY");
|
||||
|
||||
result.Applied.ShouldBeFalse();
|
||||
result.Transient.ShouldBeFalse();
|
||||
StoredKey().ShouldBe("GOOD-KEY");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unreachable_server_stores_nothing()
|
||||
{
|
||||
await using var manager = Manager();
|
||||
await manager.StartAsync();
|
||||
|
||||
var result = await manager.ApplyLicenseKeyAsync("OFFLINE-KEY");
|
||||
|
||||
result.Applied.ShouldBeFalse();
|
||||
result.Transient.ShouldBeTrue();
|
||||
manager.LicenseHolds.ShouldBeFalse();
|
||||
StoredKey().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_key_is_not_sent_anywhere()
|
||||
{
|
||||
await using var manager = Manager();
|
||||
|
||||
(await manager.ApplyLicenseKeyAsync(" ")).Applied.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_license_check_nothing_is_stored()
|
||||
{
|
||||
var registry = new InstanceRegistry(
|
||||
_directories, _launcher, _channel, NullLoggerFactory.Instance, Options);
|
||||
await using var manager = new ManagerCore(
|
||||
new ManagerSettingsStore(_root), registry, new FakeTokenSource(), NullLoggerFactory.Instance);
|
||||
|
||||
(await manager.ApplyLicenseKeyAsync("GOOD-KEY")).Applied.ShouldBeFalse();
|
||||
StoredKey().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user