Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen

Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt).
Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e):
Properties, Record-Parameter, Enum-Member sowie die Parameter der
Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder
anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-04 13:18:43 +02:00
co-authored by Claude Opus 5.5
parent 837a634a22
commit 77062facc9
190 changed files with 4813 additions and 4813 deletions
@@ -12,18 +12,18 @@ namespace ClawdDotNet.Tools.Tests.Database;
/// Pre-check (#579), approval key (#609) and permissions (#641) of the Database tool -
/// all checkable without a database connection, because it only looks at configuration and statement.
/// </summary>
public sealed class DatabaseVorpruefungTests
public sealed class DatabasePreCheckTests
{
private static readonly DatabaseTool Tool = new();
private static AgentToolContext Kontext(string accessLevel, params string[] tabellen) => new(
private static AgentToolContext Context(string accessLevel, params string[] tables) => new(
"agent", "inst",
new Dictionary<string, object?>
{
["type"] = "mysql",
["connectionString"] = "Server=nirgends",
["accessLevel"] = accessLevel,
["allowedTables"] = JsonSerializer.SerializeToElement(tabellen)
["allowedTables"] = JsonSerializer.SerializeToElement(tables)
},
new NullStateStore(), NullLogger.Instance, CancellationToken.None);
@@ -31,13 +31,13 @@ public sealed class DatabaseVorpruefungTests
=> JsonSerializer.SerializeToElement(new { action, sql });
[Fact]
public void Fremde_Tabelle_wird_vorab_abgelehnt()
=> Tool.Preflight(Sql("SELECT * FROM protokoll"), Kontext("ReadOnly", "messwerte"))
public void Foreign_table_is_rejected_up_front()
=> Tool.Preflight(Sql("SELECT * FROM protokoll"), Context("ReadOnly", "messwerte"))
.ShouldNotBeNull().ShouldContain("protokoll");
[Fact]
public void Kommentare_werden_vorab_abgelehnt()
=> Tool.Preflight(Sql("DELETE FROM messwerte -- messwerte"), Kontext("Admin", "messwerte"))
public void Comments_are_rejected_up_front()
=> Tool.Preflight(Sql("DELETE FROM messwerte -- messwerte"), Context("Admin", "messwerte"))
.ShouldNotBeNull();
[Theory]
@@ -46,11 +46,11 @@ public sealed class DatabaseVorpruefungTests
[InlineData("ALTER TABLE messwerte ADD COLUMN x INT", "ReadWrite")]
[InlineData("CREATE TABLE IF NOT EXISTS messwerte (id INT)", "Admin")]
[InlineData("DESCRIBE messwerte", "ReadOnly")]
public void Die_Vorpruefung_urteilt_nicht_mehr_ueber_die_Operation(string sql, string level)
public void The_pre_check_no_longer_judges_the_operation(string sql, string level)
{
// #641: Whether an INSERT of a reading agent runs, is presented or is forbidden
// is decided by the approval point - no longer by a silent refusal in the tool.
Tool.Preflight(Sql(sql), Kontext(level, "messwerte")).ShouldBeNull();
Tool.Preflight(Sql(sql), Context(level, "messwerte")).ShouldBeNull();
}
[Theory]
@@ -64,36 +64,36 @@ public sealed class DatabaseVorpruefungTests
[InlineData("ALTER TABLE messwerte ADD COLUMN x INT", "query", "alter")]
[InlineData("DROP TABLE IF EXISTS messwerte", "query", "drop")]
[InlineData("insert into messwerte (ort) values ('select')", "query", "insert")]
public void Der_Freigabeschluessel_ist_die_SQL_Operation_nicht_das_action_Argument(string sql, string action, string erwartet)
=> Tool.StagingAktion(Sql(sql, action)).ShouldBe(erwartet);
public void The_approval_key_is_the_SQL_operation_not_the_action_argument(string sql, string action, string expected)
=> Tool.StagingAction(Sql(sql, action)).ShouldBe(expected);
[Theory]
[InlineData("query", "select")]
[InlineData("insert", "insert")]
[InlineData("upsert", "update")]
public void MongoDB_Aktionen_werden_auf_dieselben_Operationen_abgebildet(string action, string erwartet)
=> Tool.StagingAktion(JsonSerializer.SerializeToElement(new { action, collection = "c" })).ShouldBe(erwartet);
public void MongoDB_actions_are_mapped_to_the_same_operations(string action, string expected)
=> Tool.StagingAction(JsonSerializer.SerializeToElement(new { action, collection = "c" })).ShouldBe(expected);
[Fact]
public void Die_Operationen_sind_fuer_die_Oberflaeche_aufgezaehlt()
public void The_operations_are_enumerated_for_the_UI()
=> Tool.Operationen.ShouldBe(["select", "insert", "update", "delete", "replace", "create", "alter", "drop", "truncate"]);
[Theory]
[InlineData("select", StagingDecision.Auto)]
[InlineData("insert", StagingDecision.Approve)]
[InlineData("alter", StagingDecision.Approve)]
public void Ohne_erlaubt_liest_der_Agent_frei_und_legt_alles_andere_vor(string operation, StagingDecision erwartet)
=> Tool.Vorgabe(operation, new Dictionary<string, object?>()).ShouldBe(erwartet);
public void Without_allowed_the_agent_reads_freely_and_submits_everything_else(string operation, StagingDecision expected)
=> Tool.Vorgabe(operation, new Dictionary<string, object?>()).ShouldBe(expected);
[Fact]
public async Task Ohne_Freigabestelle_bleibt_die_Zugriffsstufe_eine_harte_Grenze()
public async Task Without_an_approver_the_access_tier_stays_a_hard_limit()
{
// No gate, no check: then the tool itself has to refuse before it
// even establishes a connection.
var ergebnis = await Tool.ExecuteAsync(
Sql("INSERT INTO messwerte (ort) VALUES ('Berlin')"), Kontext("ReadOnly", "messwerte"), default);
var result = await Tool.ExecuteAsync(
Sql("INSERT INTO messwerte (ort) VALUES ('Berlin')"), Context("ReadOnly", "messwerte"), default);
ergebnis.Success.ShouldBeFalse();
ergebnis.ErrorMessage.ShouldNotBeNull().ShouldContain("ReadOnly");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("ReadOnly");
}
}
@@ -123,7 +123,7 @@ public sealed class SchemaLookupTests : IDisposable
[Fact]
public void Describe_is_a_read_and_is_checked_before_approval()
{
_tool.StagingAktion(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" })).ShouldBe("select");
_tool.StagingAction(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" })).ShouldBe("select");
_tool.Preflight(JsonSerializer.SerializeToElement(new { action = "describe", table = "geheim" }), Context("messwerte"))
.ShouldNotBeNull();
_tool.Preflight(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" }), Context("messwerte"))
@@ -30,7 +30,7 @@ public sealed class SqlGuardTests
[InlineData("DESCRIBE prices", SqlOperation.Read)]
[InlineData("DESC prices", SqlOperation.Read)]
[InlineData("SHOW COLUMNS FROM prices", SqlOperation.Read)]
public void IF_NOT_EXISTS_und_Strukturabfragen_nennen_die_richtige_Tabelle(string sql, SqlOperation operation)
public void IF_NOT_EXISTS_and_structure_queries_name_the_right_table(string sql, SqlOperation operation)
{
var result = Inspect(sql);
@@ -43,7 +43,7 @@ public sealed class SqlGuardTests
[InlineData("CREATE TABLE IF NOT EXISTS users (id INT)")]
[InlineData("DROP TABLE IF EXISTS users")]
[InlineData("DESCRIBE users")]
public void IF_EXISTS_und_DESCRIBE_oeffnen_keine_fremde_Tabelle(string sql)
public void IF_EXISTS_and_DESCRIBE_open_no_foreign_table(string sql)
=> Inspect(sql).IsValid.ShouldBeFalse();
[Theory]
@@ -52,7 +52,7 @@ public sealed class SqlGuardTests
[InlineData("DROP TABLE users -- prices")]
[InlineData("UPDATE users SET admin=1 -- prices")]
[InlineData("SELECT * FROM users # prices")]
public void Kommentare_koennen_keine_erlaubte_Tabelle_vortaeuschen(string sql)
public void Comments_cannot_fake_an_allowed_table(string sql)
{
var result = Inspect(sql);
@@ -63,7 +63,7 @@ public sealed class SqlGuardTests
[InlineData("SELECT * FROM users WHERE note = 'prices'")]
[InlineData("INSERT INTO users (name) VALUES ('prices')")]
[InlineData("SELECT * FROM users WHERE a = \"prices\"")]
public void String_Literale_koennen_keine_erlaubte_Tabelle_vortaeuschen(string sql)
public void String_literals_cannot_fake_an_allowed_table(string sql)
{
Inspect(sql).IsValid.ShouldBeFalse();
}
@@ -72,7 +72,7 @@ public sealed class SqlGuardTests
[InlineData("SELECT * FROM prices; DROP TABLE users")]
[InlineData("SELECT * FROM prices; DELETE FROM users")]
[InlineData("SELECT 1; SELECT 2")]
public void Mehrere_Statements_werden_abgelehnt(string sql)
public void Multiple_statements_are_rejected(string sql)
{
var result = Inspect(sql);
@@ -84,7 +84,7 @@ public sealed class SqlGuardTests
[InlineData("SELECT * FROM prices JOIN users ON users.id = prices.uid")]
[InlineData("SELECT * FROM prices, users")]
[InlineData("INSERT INTO users SELECT * FROM prices")]
public void Ein_einziger_unerlaubter_Join_reicht_zur_Ablehnung(string sql)
public void A_single_disallowed_join_is_enough_for_rejection(string sql)
{
// Crucial: EVERY referenced table must be allowed, not just any one.
var result = Inspect(sql);
@@ -94,7 +94,7 @@ public sealed class SqlGuardTests
}
[Fact]
public void Ohne_Whitelist_wird_nichts_durchgelassen()
public void Without_whitelist_nothing_is_let_through()
{
SqlGuard.Inspect("SELECT * FROM prices", []).IsValid.ShouldBeFalse();
}
@@ -103,7 +103,7 @@ public sealed class SqlGuardTests
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Leere_Statements_werden_abgelehnt(string? sql)
public void Empty_statements_are_rejected(string? sql)
{
SqlGuard.Inspect(sql, Allowed).IsValid.ShouldBeFalse();
}
@@ -118,7 +118,7 @@ public sealed class SqlGuardTests
[InlineData("SELECT * FROM prices JOIN signals ON signals.ticker = prices.ticker")]
[InlineData("select * from PRICES")]
[InlineData("SELECT * FROM prices;")]
public void Erlaubte_Leseabfragen_gehen_durch(string sql)
public void Allowed_read_queries_go_through(string sql)
{
var result = Inspect(sql);
@@ -130,7 +130,7 @@ public sealed class SqlGuardTests
[InlineData("SELECT * FROM prices WHERE note = 'update'")]
[InlineData("SELECT * FROM prices WHERE kommentar = 'bitte delete beachten'")]
[InlineData("SELECT * FROM prices WHERE aktion = 'drop'")]
public void Harmlose_Schluesselwoerter_in_Werten_gelten_nicht_als_Schreibzugriff(string sql)
public void Harmless_keywords_in_values_do_not_count_as_write_access(string sql)
{
// The old substring test wrongly classified these queries as write or
// structure access and blocked them for read-only agents.
@@ -147,7 +147,7 @@ public sealed class SqlGuardTests
[InlineData("DROP TABLE prices", SqlOperation.Schema)]
[InlineData("ALTER TABLE prices ADD COLUMN x INT", SqlOperation.Schema)]
[InlineData("TRUNCATE TABLE prices", SqlOperation.Schema)]
public void Die_Operation_wird_am_ersten_Schluesselwort_bestimmt(string sql, SqlOperation expected)
public void The_operation_is_determined_by_the_first_keyword(string sql, SqlOperation expected)
{
var result = Inspect(sql);
@@ -156,7 +156,7 @@ public sealed class SqlGuardTests
}
[Fact]
public void Schema_qualifizierte_Namen_werden_erkannt()
public void Schema_qualified_names_are_recognised()
{
var result = Inspect("SELECT * FROM crypto.prices WHERE id = 1");
@@ -164,7 +164,7 @@ public sealed class SqlGuardTests
}
[Fact]
public void Der_Fehlertext_nennt_die_abgelehnte_Tabelle_und_die_erlaubten()
public void The_error_text_names_the_rejected_table_and_the_allowed_ones()
{
var result = Inspect("SELECT * FROM geheim");
@@ -173,7 +173,7 @@ public sealed class SqlGuardTests
}
[Fact]
public void Escaptes_Apostroph_im_Literal_bricht_die_Erkennung_nicht()
public void Escaped_apostrophe_in_the_literal_does_not_break_detection()
{
var result = Inspect("SELECT * FROM prices WHERE name = 'O''Brien'");
@@ -22,7 +22,7 @@ public sealed class UrlSanitizerTests
[InlineData("https://api.example.com/v1?ApiKey=GEHEIM-abc123XYZ&other=2")]
[InlineData("https://api.example.com/v1?secret=GEHEIM-abc123XYZ")]
[InlineData("https://api.example.com/v1?a=1&apikey=GEHEIM-abc123XYZ#fragment")]
public void Sensible_Parameter_werden_maskiert(string url)
public void Sensitive_parameters_are_masked(string url)
{
var sanitized = UrlSanitizer.Sanitize(url);
@@ -31,7 +31,7 @@ public sealed class UrlSanitizerTests
}
[Fact]
public void Harmlose_Parameter_bleiben_lesbar()
public void Harmless_parameters_stay_readable()
{
var sanitized = UrlSanitizer.Sanitize(
"https://api.twelvedata.com/quote?symbol=NVDA&interval=1day&apikey=GEHEIM-abc123XYZ");
@@ -42,7 +42,7 @@ public sealed class UrlSanitizerTests
}
[Fact]
public void Der_Pfad_bleibt_erhalten()
public void The_path_is_kept()
{
var sanitized = UrlSanitizer.Sanitize("https://api.massive.com/v2/aggs/ticker/NVDA?apikey=x");
@@ -50,7 +50,7 @@ public sealed class UrlSanitizerTests
}
[Fact]
public void Ein_Fragment_bleibt_erhalten()
public void A_fragment_is_kept()
{
var sanitized = UrlSanitizer.Sanitize("https://x.de/a?apikey=geheim#abschnitt");
@@ -61,7 +61,7 @@ public sealed class UrlSanitizerTests
[Theory]
[InlineData("https://api.example.com/v1")]
[InlineData("https://api.example.com/v1?symbol=NVDA")]
public void URLs_ohne_sensible_Parameter_bleiben_unveraendert(string url)
public void URLs_without_sensitive_parameters_stay_unchanged(string url)
{
UrlSanitizer.Sanitize(url).ShouldBe(url);
}
@@ -70,13 +70,13 @@ public sealed class UrlSanitizerTests
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Leere_Eingaben_fuehren_nicht_zu_einem_Fehler(string? url)
public void Empty_inputs_do_not_lead_to_an_error(string? url)
{
Should.NotThrow(() => UrlSanitizer.Sanitize(url));
}
[Fact]
public void Ein_Parameter_ohne_Wert_stoert_nicht()
public void A_parameter_without_value_does_no_harm()
{
var sanitized = UrlSanitizer.Sanitize("https://x.de/a?flag&apikey=geheim");
@@ -85,7 +85,7 @@ public sealed class UrlSanitizerTests
}
[Fact]
public void Ein_Schluessel_als_Teil_eines_anderen_Namens_wird_nicht_faelschlich_maskiert()
public void A_key_as_part_of_another_name_is_not_wrongly_masked()
{
// "monkey" contains "key", but is not a sensitive parameter.
var sanitized = UrlSanitizer.Sanitize("https://x.de/a?monkey=banane");
@@ -14,14 +14,14 @@ namespace ClawdDotNet.Tools.Tests.FileRW;
/// at the step limit, and the same agent repeatedly fetched files in the wrong workspace,
/// because the error message did not say where they lie.
/// </summary>
public sealed class SammelLesenTests : IDisposable
public sealed class BatchReadTests : IDisposable
{
private readonly string _basis;
private readonly string _personal;
private readonly string _shared;
private readonly FileRWTool _tool = new();
public SammelLesenTests()
public BatchReadTests()
{
_basis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_personal = Path.Combine(_basis, "Workspace");
@@ -48,23 +48,23 @@ public sealed class SammelLesenTests : IDisposable
private Task<ToolResult> Run(string json, string sharedLevel = "ReadWrite")
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(sharedLevel), CancellationToken.None);
private void Stundendateien(int anzahl, string tag = "2026-09-20")
private void HourlyFiles(int count, string tag = "2026-09-20")
{
for (var stunde = 0; stunde < anzahl; stunde++)
for (var hour = 0; hour < count; hour++)
{
File.WriteAllText(
Path.Combine(_shared, "wetter", "stunden", $"berlin_{tag}_{stunde:00}-00.txt"),
$"{tag} {stunde:00}:00 Temperatur {10 + stunde} C");
Path.Combine(_shared, "wetter", "stunden", $"berlin_{tag}_{hour:00}-00.txt"),
$"{tag} {hour:00}:00 Temperatur {10 + hour} C");
}
}
// ═══════════════════ #663: one call instead of 24 ═══════════════════
[Fact]
public async Task Ein_Verzeichnis_mit_Muster_kommt_in_einem_Aufruf()
public async Task A_directory_with_pattern_comes_in_one_call()
{
Stundendateien(24);
Stundendateien(3, "2026-09-19");
HourlyFiles(24);
HourlyFiles(3, "2026-09-19");
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden","pattern":"berlin_2026-09-20_*.txt"}""");
@@ -76,23 +76,23 @@ public sealed class SammelLesenTests : IDisposable
}
[Fact]
public async Task Die_Dateien_kommen_benannt_und_in_fester_Reihenfolge()
public async Task The_files_come_named_and_in_fixed_order()
{
Stundendateien(3);
HourlyFiles(3);
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
var reihenfolge = new[] { "00-00.txt", "01-00.txt", "02-00.txt" }
var sequence = new[] { "00-00.txt", "01-00.txt", "02-00.txt" }
.Select(n => result.Content.IndexOf(n, StringComparison.Ordinal)).ToList();
reihenfolge.ShouldAllBe(i => i > 0);
reihenfolge.ShouldBe(reihenfolge.OrderBy(i => i).ToList(), "sortiert, damit der Agent den Tag zusammensetzen kann");
sequence.ShouldAllBe(i => i > 0);
sequence.ShouldBe(sequence.OrderBy(i => i).ToList(), "sortiert, damit der Agent den Tag zusammensetzen kann");
result.Content.ShouldContain("--- shared:/wetter/stunden/berlin_2026-09-20_01-00.txt ---");
}
[Fact]
public async Task Eine_ausdrueckliche_Liste_wird_gelesen_und_Fehlendes_benannt()
public async Task An_explicit_list_is_read_and_missing_ones_named()
{
Stundendateien(2);
HourlyFiles(2);
var result = await Run("""
{"action":"read_many","workspace":"shared","paths":[
@@ -106,20 +106,20 @@ public sealed class SammelLesenTests : IDisposable
}
[Fact]
public async Task Zu_viele_Dateien_werden_gedeckelt_und_der_Rest_genannt()
public async Task Too_many_files_are_capped_and_the_rest_named()
{
Stundendateien(60);
HourlyFiles(60);
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain($"{FileRWTool.MaxDateienJeAufruf} of 60 file(s) read");
result.Content.ShouldContain($"{FileRWTool.MaxFilesPerCall} of 60 file(s) read");
result.Content.ShouldContain("Skipped reading");
result.Content.ShouldContain("fetch with an additional call");
}
[Fact]
public async Task Ein_leeres_Verzeichnis_meldet_sich_als_Fehler()
public async Task An_empty_directory_reports_as_an_error()
{
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
@@ -128,9 +128,9 @@ public sealed class SammelLesenTests : IDisposable
}
[Fact]
public async Task Ohne_Leserecht_am_gemeinsamen_Bereich_kommt_nichts_heraus()
public async Task Without_read_right_on_the_shared_area_nothing_comes_out()
{
Stundendateien(2);
HourlyFiles(2);
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""", sharedLevel: "Denied");
@@ -139,7 +139,7 @@ public sealed class SammelLesenTests : IDisposable
}
[Fact]
public async Task Der_Ausbruch_aus_dem_Workspace_wird_auch_hier_abgewiesen()
public async Task Escaping_the_workspace_is_rejected_here_too()
{
var result = await Run("""{"action":"read_many","workspace":"shared","paths":["../../geheim.txt"]}""");
@@ -148,9 +148,9 @@ public sealed class SammelLesenTests : IDisposable
}
[Fact]
public async Task Eine_verbotene_Dateiendung_wird_uebergangen_statt_den_Lauf_zu_kippen()
public async Task A_forbidden_file_extension_is_skipped_instead_of_toppling_the_run()
{
Stundendateien(2);
HourlyFiles(2);
File.WriteAllText(Path.Combine(_shared, "wetter", "stunden", "skript.exe"), "MZ");
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
@@ -163,9 +163,9 @@ public sealed class SammelLesenTests : IDisposable
// ═══════════════════ #662: hint about the other workspace ═══════════════════
[Fact]
public async Task Liegt_die_Datei_im_anderen_Workspace_sagt_die_Meldung_das()
public async Task If_the_file_is_in_the_other_workspace_the_message_says_so()
{
Stundendateien(1);
HourlyFiles(1);
var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}""");
@@ -175,7 +175,7 @@ public sealed class SammelLesenTests : IDisposable
}
[Fact]
public async Task Auch_ein_Verzeichnis_bekommt_den_Hinweis()
public async Task A_directory_gets_the_hint_too()
{
var result = await Run("""{"action":"list","path":"wetter/stunden"}""");
@@ -184,10 +184,10 @@ public sealed class SammelLesenTests : IDisposable
}
[Fact]
public async Task Ohne_Recht_am_anderen_Workspace_gibt_es_keinen_Hinweis()
public async Task Without_right_on_the_other_workspace_there_is_no_hint()
{
// A hint about something the agent may not do anyway sends it into nothing.
Stundendateien(1);
HourlyFiles(1);
var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}""", sharedLevel: "Denied");
@@ -196,7 +196,7 @@ public sealed class SammelLesenTests : IDisposable
}
[Fact]
public async Task Gibt_es_den_Pfad_nirgends_bleibt_die_Meldung_schlicht()
public async Task If_the_path_exists_nowhere_the_message_stays_plain()
{
var result = await Run("""{"action":"read","workspace":"shared","path":"wetter/stunden/fehlt.txt"}""");
@@ -14,14 +14,14 @@ namespace ClawdDotNet.Tools.Tests.FileRW;
/// file system — finding two names in that is search work, and a cheap model
/// does it wrong.
/// </summary>
public sealed class VerzeichnisListeTests : IDisposable
public sealed class DirectoryListTests : IDisposable
{
private readonly string _basis;
private readonly string _personal;
private readonly string _shared;
private readonly FileRWTool _tool = new();
public VerzeichnisListeTests()
public DirectoryListTests()
{
_basis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_personal = Path.Combine(_basis, "Workspace");
@@ -49,18 +49,18 @@ public sealed class VerzeichnisListeTests : IDisposable
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
/// <summary>Hourly files, deliberately written in mixed order.</summary>
private void Stundendateien(int anzahl, string tag = "2026-09-21")
private void HourlyFiles(int count, string tag = "2026-09-21")
{
var verzeichnis = Path.Combine(_shared, "wetter", "stunden");
foreach (var stunde in Enumerable.Range(0, anzahl).OrderBy(_ => Guid.NewGuid()))
foreach (var hour in Enumerable.Range(0, count).OrderBy(_ => Guid.NewGuid()))
{
var pfad = Path.Combine(verzeichnis, $"berlin_{tag}_{stunde:00}-00.txt");
File.WriteAllText(pfad, $"{tag} {stunde:00}:00 Temperatur {10 + stunde} C");
File.SetLastWriteTime(pfad, DateTime.Parse($"{tag}T{stunde:00}:05:00"));
var path = Path.Combine(verzeichnis, $"berlin_{tag}_{hour:00}-00.txt");
File.WriteAllText(path, $"{tag} {hour:00}:00 Temperatur {10 + hour} C");
File.SetLastWriteTime(path, DateTime.Parse($"{tag}T{hour:00}:05:00"));
}
}
private static List<string> Namen(string inhalt) => inhalt
private static List<string> Names(string content) => content
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(z => z.Trim())
.Where(z => z.StartsWith("20", StringComparison.Ordinal))
@@ -70,90 +70,90 @@ public sealed class VerzeichnisListeTests : IDisposable
// ═══════════════════ Order ═══════════════════
[Fact]
public async Task Die_juengste_Datei_steht_oben()
public async Task The_newest_file_is_on_top()
{
Stundendateien(6);
HourlyFiles(6);
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("newest first");
Namen(result.Content).First().ShouldBe("berlin_2026-09-21_05-00.txt");
Namen(result.Content).Last().ShouldBe("berlin_2026-09-21_00-00.txt");
Names(result.Content).First().ShouldBe("berlin_2026-09-21_05-00.txt");
Names(result.Content).Last().ShouldBe("berlin_2026-09-21_00-00.txt");
}
[Fact]
public async Task Nach_Namen_sortiert_laeuft_aufsteigend()
public async Task Sorted_by_name_runs_ascending()
{
Stundendateien(4);
HourlyFiles(4);
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"name"}""");
Namen(result.Content).ShouldBe([
Names(result.Content).ShouldBe([
"berlin_2026-09-21_00-00.txt", "berlin_2026-09-21_01-00.txt",
"berlin_2026-09-21_02-00.txt", "berlin_2026-09-21_03-00.txt"
]);
}
[Fact]
public async Task Die_Richtung_laesst_sich_umkehren()
public async Task The_direction_can_be_reversed()
{
Stundendateien(4);
HourlyFiles(4);
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"modified","order":"asc"}""");
result.Content.ShouldContain("oldest first");
Namen(result.Content).First().ShouldBe("berlin_2026-09-21_00-00.txt");
Names(result.Content).First().ShouldBe("berlin_2026-09-21_00-00.txt");
}
// ═══════════════════ Cap and total count ═══════════════════
[Fact]
public async Task Der_Kopf_nennt_immer_die_Gesamtzahl()
public async Task The_header_always_names_the_total()
{
Stundendateien(24);
Stundendateien(24, "2026-09-20");
Stundendateien(24, "2026-09-19");
HourlyFiles(24);
HourlyFiles(24, "2026-09-20");
HourlyFiles(24, "2026-09-19");
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
result.Content.ShouldContain($"{FileRWTool.StandardEintraege} of 72 entries");
result.Content.ShouldContain($"{FileRWTool.DefaultEntries} of 72 entries");
result.Content.ShouldContain("22 more not shown");
result.Content.ShouldContain("'pattern'", Case.Sensitive, "der Agent soll wissen, wie er gezielt fragt");
Namen(result.Content).Count.ShouldBe(FileRWTool.StandardEintraege);
Names(result.Content).Count.ShouldBe(FileRWTool.DefaultEntries);
}
[Fact]
public async Task Ein_eigenes_Limit_wird_beachtet_und_gedeckelt()
public async Task A_custom_limit_is_respected_and_capped()
{
Stundendateien(10);
HourlyFiles(10);
var wenig = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":3}""");
Namen(wenig.Content).Count.ShouldBe(3);
wenig.Content.ShouldContain("3 of 10 entries");
var little = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":3}""");
Names(little.Content).Count.ShouldBe(3);
little.Content.ShouldContain("3 of 10 entries");
var zuviel = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":99999}""");
Namen(zuviel.Content).Count.ShouldBe(10, "mehr als da ist gibt es nicht");
var tooMuch = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":99999}""");
Names(tooMuch.Content).Count.ShouldBe(10, "mehr als da ist gibt es nicht");
}
// ═══════════════════ Asking specifically ═══════════════════
[Fact]
public async Task Ein_Muster_beantwortet_die_Frage_nach_einer_bestimmten_Datei()
public async Task A_pattern_answers_the_question_for_a_specific_file()
{
Stundendateien(24);
Stundendateien(24, "2026-09-20");
HourlyFiles(24);
HourlyFiles(24, "2026-09-20");
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","pattern":"berlin_2026-09-21_1?-00.txt"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("10 of 10 entries");
result.Content.ShouldContain("Pattern:");
Namen(result.Content).ShouldAllBe(n => n.StartsWith("berlin_2026-09-21_1"));
Names(result.Content).ShouldAllBe(n => n.StartsWith("berlin_2026-09-21_1"));
}
[Fact]
public async Task Ein_leeres_Verzeichnis_ist_kein_Fehler_sondern_eine_Null()
public async Task An_empty_directory_is_not_an_error_but_a_zero()
{
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
@@ -164,9 +164,9 @@ public sealed class VerzeichnisListeTests : IDisposable
// ═══════════════════ Form ═══════════════════
[Fact]
public async Task Jeder_Eintrag_steht_auf_genau_einer_Zeile()
public async Task Every_entry_is_on_exactly_one_line()
{
Stundendateien(3);
HourlyFiles(3);
Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden", "alt"));
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
@@ -174,7 +174,7 @@ public sealed class VerzeichnisListeTests : IDisposable
result.Content.ShouldNotContain("\"Name\":", Case.Sensitive, "kein eingeruecktes JSON mehr");
result.Content.ShouldContain("alt/", Case.Sensitive);
result.Content.ShouldContain("<DIR>", Case.Sensitive);
Namen(result.Content).Count.ShouldBe(4);
Names(result.Content).Count.ShouldBe(4);
}
// ═══════════════════ The pattern is not a second path ═══════════════════
@@ -182,11 +182,11 @@ public sealed class VerzeichnisListeTests : IDisposable
[Theory]
[InlineData("list")]
[InlineData("read_many")]
public async Task Ein_Muster_darf_nicht_aus_dem_Verzeichnis_herausfuehren(string action)
public async Task A_pattern_must_not_lead_out_of_the_directory(string action)
{
// Directory.GetFiles evaluates separators in the pattern: "../*" lists the
// parent directory without the path check ever being asked.
Stundendateien(2);
HourlyFiles(2);
File.WriteAllText(Path.Combine(_shared, "wetter", "geheim.txt"), "nicht fuer den Agenten");
var result = await Run($$"""{"action":"{{action}}","workspace":"shared","path":"wetter/stunden","pattern":"../*"}""");
@@ -200,12 +200,12 @@ public sealed class VerzeichnisListeTests : IDisposable
[InlineData("unter/*.txt")]
[InlineData("..\\*")]
[InlineData("*/../*")]
public async Task Auch_die_anderen_Pfadschreibweisen_werden_abgewiesen(string muster)
public async Task The_other_path_spellings_are_rejected_too(string pattern)
{
Stundendateien(2);
HourlyFiles(2);
var input = JsonSerializer.SerializeToElement(
new { action = "list", workspace = "shared", path = "wetter/stunden", pattern = muster });
new { action = "list", workspace = "shared", path = "wetter/stunden", pattern = pattern });
var result = await _tool.ExecuteAsync(input, Context(), CancellationToken.None);
result.Success.ShouldBeFalse();
@@ -13,12 +13,12 @@ namespace ClawdDotNet.Tools.Tests.FileRW;
/// The file system is not abstracted here either — this is precisely about what is on
/// disk after a call was interrupted.
/// </summary>
public sealed class NachschauTests : IDisposable
public sealed class FollowUpCheckTests : IDisposable
{
private readonly string _root;
private readonly FileRWTool _tool = new();
public NachschauTests()
public FollowUpCheckTests()
{
_root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace");
Directory.CreateDirectory(_root);
@@ -47,7 +47,7 @@ public sealed class NachschauTests : IDisposable
// ═══════════════════ #553: writing atomically ═══════════════════
[Fact]
public async Task Schreiben_hinterlaesst_keine_Nebendateien()
public async Task Writing_leaves_no_side_files()
{
var result = await Run("""{"action":"write","path":"a.txt","content":"Inhalt"}""");
result.Success.ShouldBeTrue();
@@ -58,7 +58,7 @@ public sealed class NachschauTests : IDisposable
}
[Fact]
public async Task Ueberschreiben_ersetzt_den_Inhalt_vollstaendig()
public async Task Overwriting_replaces_the_content_completely()
{
await Run("""{"action":"write","path":"a.txt","content":"ein sehr langer alter Inhalt"}""");
await Run("""{"action":"write","path":"a.txt","content":"kurz"}""");
@@ -70,70 +70,70 @@ public sealed class NachschauTests : IDisposable
// ═══════════════════ #555: follow-up check for write ═══════════════════
[Fact]
public async Task Fehlende_Datei_heisst_nichts_geschehen()
public async Task Missing_file_means_nothing_happened()
{
var befund = await Probe("""{"action":"write","path":"nie.txt","content":"x"}""");
var finding = await Probe("""{"action":"write","path":"nie.txt","content":"x"}""");
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
finding.Outcome.ShouldBe(ProbeOutcome.NotStarted);
}
[Fact]
public async Task Der_beabsichtigte_Inhalt_heisst_durchgekommen()
public async Task The_intended_content_means_through()
{
await Run("""{"action":"write","path":"a.txt","content":"fertig"}""");
var befund = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}""");
var finding = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
finding.Outcome.ShouldBe(ProbeOutcome.Complete);
}
[Fact]
public async Task Der_alte_Inhalt_heisst_nicht_durchgekommen()
public async Task The_old_content_means_not_through()
{
await Run("""{"action":"write","path":"a.txt","content":"alt"}""");
// This is what it would look like if the crash came before the replacement.
var befund = await Probe("""{"action":"write","path":"a.txt","content":"neu"}""");
var finding = await Probe("""{"action":"write","path":"a.txt","content":"neu"}""");
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
finding.Outcome.ShouldBe(ProbeOutcome.NotStarted);
}
// ═══════════════════ #555: follow-up check for append ═══════════════════
[Fact]
public async Task Vollstaendig_angehaengter_Text_wird_erkannt()
public async Task Fully_appended_text_is_recognised()
{
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
var finding = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
finding.Outcome.ShouldBe(ProbeOutcome.Complete);
}
[Fact]
public async Task Ein_halb_angehaengter_Text_wird_als_Riss_erkannt()
public async Task A_half_appended_text_is_recognised_as_a_tear()
{
// Appending is not atomic — exactly this state can really arise.
// Here it is produced by hand, because a real tear cannot be reproduced.
var pfad = Path.Combine(_root, "log.txt");
await File.WriteAllTextAsync(pfad, "Zeile 1\nZeile 2 ist nur halb ang");
var path = Path.Combine(_root, "log.txt");
await File.WriteAllTextAsync(path, "Zeile 1\nZeile 2 ist nur halb ang");
var befund = await Probe(
var finding = await Probe(
"""{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Partial);
befund.Evidence.ShouldContain("of");
finding.Outcome.ShouldBe(ProbeOutcome.Partial);
finding.Evidence.ShouldContain("of");
}
[Fact]
public async Task Fehlt_der_Text_ganz_gilt_der_Aufruf_als_nicht_geschehen()
public async Task If_the_text_is_missing_entirely_the_call_counts_as_not_happened()
{
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
var finding = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
finding.Outcome.ShouldBe(ProbeOutcome.NotStarted);
}
// ═══════════════════ The limits of the follow-up check ═══════════════════
@@ -142,21 +142,21 @@ public sealed class NachschauTests : IDisposable
[InlineData("read")]
[InlineData("delete")]
[InlineData("list")]
public async Task Fuer_andere_Aktionen_wird_nichts_behauptet(string aktion)
public async Task Nothing_is_claimed_for_other_actions(string action)
{
var befund = await Probe($$"""{"action":"{{aktion}}","path":"a.txt"}""");
var finding = await Probe($$"""{"action":"{{action}}","path":"a.txt"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
finding.Outcome.ShouldBe(ProbeOutcome.Unknown);
}
[Fact]
public async Task Ein_unbrauchbarer_Pfad_ergibt_Unbekannt_statt_einer_Ausnahme()
public async Task An_unusable_path_gives_unknown_instead_of_an_exception()
{
// The follow-up check must never throw: a failure of the follow-up check is a statement
// about the follow-up check, not about the world.
var befund = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}""");
var finding = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
finding.Outcome.ShouldBe(ProbeOutcome.Unknown);
}
}
@@ -57,7 +57,7 @@ public sealed class ReversibleDeleteTests : IDisposable
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context ?? Context(), CancellationToken.None);
private string? KeyOf(object input, AgentToolContext? context = null)
=> _tool.StagingAktion(JsonSerializer.SerializeToElement(input), context ?? Context());
=> _tool.StagingAction(JsonSerializer.SerializeToElement(input), context ?? Context());
private void Put(string relative, string content = "x")
{
@@ -45,7 +45,7 @@ public sealed class WorkspacePathTests : IDisposable
[InlineData("unterordner/../../../ausserhalb.txt")]
[InlineData("./../../evil.txt")]
[InlineData("..")]
public void Relative_Ausbrueche_werden_abgelehnt(string path)
public void Relative_escapes_are_rejected(string path)
{
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
}
@@ -68,7 +68,7 @@ public sealed class WorkspacePathTests : IDisposable
[InlineData(@"\\server\share\evil.txt")]
[InlineData("//server/share/evil.txt")]
[InlineData(@"C:\temp\datei.txt")]
public void Absolute_Pfade_und_UNC_Freigaben_werden_abgelehnt(string path)
public void Absolute_paths_and_UNC_shares_are_rejected(string path)
{
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
}
@@ -82,7 +82,7 @@ public sealed class WorkspacePathTests : IDisposable
[InlineData("/etc/passwd")]
[InlineData("/root/.ssh/id_rsa")]
[InlineData("../../../../etc/shadow")]
public void Unix_Systempfade_werden_abgelehnt(string path)
public void Unix_system_paths_are_rejected(string path)
{
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
}
@@ -90,7 +90,7 @@ public sealed class WorkspacePathTests : IDisposable
[Theory]
[InlineData("datei.txt:versteckt")]
[InlineData("datei.txt:$DATA")]
public void Alternate_Data_Streams_werden_abgelehnt(string path)
public void Alternate_data_streams_are_rejected(string path)
{
// An ADS would otherwise bypass the extension check: "x.txt:evil.exe".
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
@@ -100,7 +100,7 @@ public sealed class WorkspacePathTests : IDisposable
/// The actual core of S6: a neighboring directory with the same prefix.
/// </summary>
[Fact]
public void Ein_Nachbarverzeichnis_mit_gleichem_Praefix_gilt_als_ausserhalb()
public void A_sibling_directory_with_the_same_prefix_counts_as_outside()
{
var backup = _root + "-Backup";
@@ -108,7 +108,7 @@ public sealed class WorkspacePathTests : IDisposable
}
[Fact]
public void Auch_bei_Rootangabe_mit_Trenner_bleibt_das_Nachbarverzeichnis_aussen()
public void Even_with_root_given_with_separator_the_sibling_directory_stays_outside()
{
var rootWithSeparator = _root + Path.DirectorySeparatorChar;
@@ -126,7 +126,7 @@ public sealed class WorkspacePathTests : IDisposable
[InlineData("a/b/c/tief.json")]
[InlineData("./bericht.md")]
[InlineData("unterordner/../bericht.md")]
public void Pfade_innerhalb_des_Workspace_werden_aufgeloest(string path)
public void Paths_inside_the_workspace_are_resolved(string path)
{
var resolved = Resolve(path);
@@ -138,7 +138,7 @@ public sealed class WorkspacePathTests : IDisposable
[InlineData("")]
[InlineData(" ")]
[InlineData(".")]
public void Leere_Angaben_ergeben_das_Wurzelverzeichnis(string? path)
public void Empty_values_give_the_root_directory(string? path)
{
var resolved = Resolve(path);
@@ -147,13 +147,13 @@ public sealed class WorkspacePathTests : IDisposable
}
[Fact]
public void Das_Wurzelverzeichnis_selbst_gilt_als_innerhalb()
public void The_root_directory_itself_counts_as_inside()
{
WorkspacePath.IsInside(_root, _root).ShouldBeTrue();
}
[Fact]
public void Ein_Unterverzeichnis_mit_aehnlichem_Namen_bleibt_innerhalb()
public void A_subdirectory_with_a_similar_name_stays_inside()
{
// Counter-check to the prefix rule: within the root everything is allowed.
var inner = Path.Combine(_root, "Workspace-Backup", "x.txt");
@@ -33,82 +33,82 @@ public sealed class MemoryToolTests : IDisposable
catch { /* Cleaning up is secondary */ }
}
private Task<ToolResult> Aufruf(object eingabe) => _tool.ExecuteAsync(
JsonSerializer.SerializeToElement(eingabe),
private Task<ToolResult> Call(object input) => _tool.ExecuteAsync(
JsonSerializer.SerializeToElement(input),
new AgentToolContext("nachrichten", "inst", new Dictionary<string, object?>(), new NullStateStore(),
NullLogger.Instance, CancellationToken.None, Memory: _repository),
CancellationToken.None);
private static string Linkliste(int anzahl)
=> string.Join("\n", Enumerable.Range(1, anzahl).Select(i => $"https://www.tagesschau.de/inland/meldung-{i:D4}.html"));
private static string LinkList(int count)
=> string.Join("\n", Enumerable.Range(1, count).Select(i => $"https://www.tagesschau.de/inland/meldung-{i:D4}.html"));
[Fact]
public async Task Ein_einzelner_uebergrosser_Eintrag_wird_gekuerzt_statt_weggelassen()
public async Task A_single_oversized_entry_is_shortened_instead_of_dropped()
{
var liste = Linkliste(250); // ~11,500 characters
(await Aufruf(new { action = "remember", subject = "gemeldet", content = liste })).Success.ShouldBeTrue();
var list = LinkList(250); // ~11,500 characters
(await Call(new { action = "remember", subject = "gemeldet", content = list })).Success.ShouldBeTrue();
var abruf = await Aufruf(new { action = "recall", query = "gemeldet" });
var fetch = await Call(new { action = "recall", query = "gemeldet" });
abruf.Success.ShouldBeTrue();
abruf.Content.ShouldContain("meldung-0001.html", customMessage: "der Anfang bleibt sichtbar");
abruf.Content.ShouldContain("meldung-0250.html", customMessage: "das Ende - die neuesten Links - bleibt sichtbar");
abruf.Content.ShouldContain("truncated");
abruf.Content.ShouldNotContain("more results omitted");
abruf.Content.Length.ShouldBeLessThanOrEqualTo(8_000);
fetch.Success.ShouldBeTrue();
fetch.Content.ShouldContain("meldung-0001.html", customMessage: "der Anfang bleibt sichtbar");
fetch.Content.ShouldContain("meldung-0250.html", customMessage: "das Ende - die neuesten Links - bleibt sichtbar");
fetch.Content.ShouldContain("truncated");
fetch.Content.ShouldNotContain("more results omitted");
fetch.Content.Length.ShouldBeLessThanOrEqualTo(8_000);
}
[Fact]
public async Task Ist_der_neueste_von_mehreren_Eintraegen_zu_gross_bleibt_er_teilweise_sichtbar()
public async Task If_the_newest_of_several_entries_is_too_large_it_stays_partly_visible()
{
await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(100) });
await Call(new { action = "remember", subject = "gemeldet", content = LinkList(100) });
await Task.Delay(20);
await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(220) });
await Call(new { action = "remember", subject = "gemeldet", content = LinkList(220) });
var abruf = await Aufruf(new { action = "recall", query = "gemeldet" });
var fetch = await Call(new { action = "recall", query = "gemeldet" });
abruf.Content.ShouldContain("meldung-0220.html");
abruf.Content.ShouldContain("1 more results omitted");
abruf.Content.Length.ShouldBeLessThanOrEqualTo(8_000);
fetch.Content.ShouldContain("meldung-0220.html");
fetch.Content.ShouldContain("1 more results omitted");
fetch.Content.Length.ShouldBeLessThanOrEqualTo(8_000);
}
[Fact]
public async Task Was_passt_bleibt_ungekuerzt()
public async Task What_fits_stays_unshortened()
{
await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(20) });
await Call(new { action = "remember", subject = "gemeldet", content = LinkList(20) });
var abruf = await Aufruf(new { action = "recall", query = "gemeldet" });
var fetch = await Call(new { action = "recall", query = "gemeldet" });
abruf.Content.ShouldNotContain("truncated");
abruf.Content.ShouldContain("meldung-0010.html");
fetch.Content.ShouldNotContain("truncated");
fetch.Content.ShouldContain("meldung-0010.html");
}
[Fact]
public async Task Erneutes_Merken_ohne_Schluessel_unter_demselben_Betreff_weist_auf_key_hin()
public async Task Remembering_again_without_key_under_the_same_subject_points_to_key()
{
var erster = await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(3) });
var zweiter = await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(5) });
var firstOne = await Call(new { action = "remember", subject = "gemeldet", content = LinkList(3) });
var secondOne = await Call(new { action = "remember", subject = "gemeldet", content = LinkList(5) });
erster.Content.ShouldNotContain("Note:");
zweiter.Content.ShouldContain("2 entries without a key");
zweiter.Content.ShouldContain("key");
firstOne.Content.ShouldNotContain("Note:");
secondOne.Content.ShouldContain("2 entries without a key");
secondOne.Content.ShouldContain("key");
}
[Fact]
public async Task Mit_Schluessel_wird_fortgeschrieben_und_nicht_gewarnt()
public async Task With_key_it_is_updated_and_not_warned()
{
await Aufruf(new { action = "remember", subject = "gemeldet", key = "gemeldete-links", content = Linkliste(3) });
var zweiter = await Aufruf(new { action = "remember", subject = "gemeldet", key = "gemeldete-links", content = Linkliste(5) });
await Call(new { action = "remember", subject = "gemeldet", key = "gemeldete-links", content = LinkList(3) });
var secondOne = await Call(new { action = "remember", subject = "gemeldet", key = "gemeldete-links", content = LinkList(5) });
zweiter.Content.ShouldNotContain("Note:");
secondOne.Content.ShouldNotContain("Note:");
(await _repository.CountAsync(MemoryScope.Agent, "nachrichten", CancellationToken.None)).ShouldBe(1);
}
[Fact]
public async Task Ein_Eintrag_nahe_der_Abrufgrenze_wird_beim_Merken_angemahnt()
public async Task An_entry_near_the_recall_limit_is_flagged_when_remembering()
{
var ergebnis = await Aufruf(new { action = "remember", subject = "gemeldet", key = "links", content = Linkliste(120) });
var result = await Call(new { action = "remember", subject = "gemeldet", key = "links", content = LinkList(120) });
ergebnis.Content.ShouldContain("characters long");
result.Content.ShouldContain("characters long");
}
}
@@ -6,19 +6,19 @@ using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Vorpruefung;
namespace ClawdDotNet.Tools.Tests.PreCheck;
/// <summary>
/// The pre-check of the tools (#579) and the hardening of the AgentEditor (#205): what the
/// tool would refuse anyway should not even get into the approval list.
/// </summary>
public sealed class VorpruefungTests : IDisposable
public sealed class PreCheckTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly string _agents;
private readonly string _shared;
public VorpruefungTests()
public PreCheckTests()
{
_agents = Path.Combine(_root, "Agents");
_shared = Path.Combine(_agents, "SharedWorkspace");
@@ -39,7 +39,7 @@ public sealed class VorpruefungTests : IDisposable
catch { /* Cleaning up is secondary */ }
}
private AgentToolContext Kontext(string agentId, Dictionary<string, object?>? config = null) => new(
private AgentToolContext Context(string agentId, Dictionary<string, object?>? config = null) => new(
agentId, "inst",
config ?? new Dictionary<string, object?>(),
new NullStateStore(),
@@ -55,64 +55,64 @@ public sealed class VorpruefungTests : IDisposable
[Theory]
[InlineData("Read")]
[InlineData("ReadWrite")]
public void FileRW_lehnt_Loeschen_im_gemeinsamen_Ordner_ohne_Admin_vorab_ab(string level)
public void FileRW_rejects_deleting_in_the_shared_folder_without_admin_up_front(string level)
=> new FileRWTool()
.Preflight(Json("""{"action":"delete","workspace":"shared","path":"Testplan.md"}"""),
Kontext("dateipruefer", new() { ["sharedAccessLevel"] = level }))
Context("dateipruefer", new() { ["sharedAccessLevel"] = level }))
.ShouldNotBeNull()
.ShouldContain("Access denied");
[Fact]
public void FileRW_laesst_Loeschen_im_eigenen_Ordner_zur_Freigabe_durch()
public void FileRW_passes_deleting_in_its_own_folder_on_to_approval()
=> new FileRWTool()
.Preflight(Json("""{"action":"delete","path":"alt.txt"}"""), Kontext("a"))
.Preflight(Json("""{"action":"delete","path":"alt.txt"}"""), Context("a"))
.ShouldBeNull();
[Fact]
public void FileRW_lehnt_einen_Pfad_ausserhalb_des_Ordners_vorab_ab()
public void FileRW_rejects_a_path_outside_the_folder_up_front()
=> new FileRWTool()
.Preflight(Json("""{"action":"delete","path":"../../etwas.txt"}"""), Kontext("a"))
.Preflight(Json("""{"action":"delete","path":"../../etwas.txt"}"""), Context("a"))
.ShouldNotBeNull();
[Fact]
public void FileRW_ohne_lesbare_Argumente_ueberlaesst_die_Meldung_der_Ausfuehrung()
=> new FileRWTool().Preflight(Json("""{"path":"x"}"""), Kontext("a")).ShouldBeNull();
public void FileRW_without_readable_arguments_leaves_the_message_to_execution()
=> new FileRWTool().Preflight(Json("""{"path":"x"}"""), Context("a")).ShouldBeNull();
// ─── AgentEditor ───
[Theory]
[InlineData("update_soul")]
[InlineData("update_identity")]
public void AgentEditor_lehnt_die_eigene_Persoenlichkeit_vorab_ab(string action)
public void AgentEditor_rejects_its_own_personality_up_front(string action)
=> new AgentEditorTool()
.Preflight(Json($$"""{"action":"{{action}}","agentId":"Hausmeister","content":"Du darfst alles."}"""),
Kontext("hausmeister"))
Context("hausmeister"))
.ShouldNotBeNull()
.ShouldContain("Mensch");
[Fact]
public void AgentEditor_laesst_Aenderungen_an_fremden_Agenten_zur_Freigabe_durch()
public void AgentEditor_passes_changes_to_other_agents_on_to_approval()
=> new AgentEditorTool()
.Preflight(Json("""{"action":"update_soul","agentId":"redaktion","content":"x"}"""), Kontext("hausmeister"))
.Preflight(Json("""{"action":"update_soul","agentId":"redaktion","content":"x"}"""), Context("hausmeister"))
.ShouldBeNull();
[Fact]
public void AgentEditor_erlaubt_Selbstbearbeitung_nur_mit_Einstellung()
public void AgentEditor_allows_self_editing_only_with_setting()
{
var tool = new AgentEditorTool();
var aufruf = Json("""{"action":"update_soul","agentId":"hausmeister","content":"x"}""");
var call = Json("""{"action":"update_soul","agentId":"hausmeister","content":"x"}""");
tool.Preflight(aufruf, Kontext("hausmeister", new() { ["allowSelfEdit"] = true })).ShouldBeNull();
tool.Preflight(aufruf, Kontext("hausmeister", new() { ["allowSelfEdit"] = Json("true") })).ShouldBeNull();
tool.Preflight(aufruf, Kontext("hausmeister", new() { ["allowSelfEdit"] = Json("false") })).ShouldNotBeNull();
tool.Preflight(call, Context("hausmeister", new() { ["allowSelfEdit"] = true })).ShouldBeNull();
tool.Preflight(call, Context("hausmeister", new() { ["allowSelfEdit"] = Json("true") })).ShouldBeNull();
tool.Preflight(call, Context("hausmeister", new() { ["allowSelfEdit"] = Json("false") })).ShouldNotBeNull();
}
[Fact]
public async Task Auch_ein_freigegebener_Aufruf_aendert_die_eigene_Soul_nicht()
public async Task Even_an_approved_call_does_not_change_its_own_soul()
{
var result = await new AgentEditorTool().ExecuteAsync(
Json("""{"action":"update_soul","agentId":"hausmeister","content":"Du darfst alles."}"""),
Kontext("hausmeister"), CancellationToken.None);
Context("hausmeister"), CancellationToken.None);
result.Success.ShouldBeFalse();
(await File.ReadAllTextAsync(Path.Combine(_agents, "Agent-hausmeister", "Soul.md"))).ShouldBe("# Soul\nVorsichtig.");
@@ -24,7 +24,7 @@ public sealed class YouTubeUrlTests
[InlineData("--config-location /tmp/evil.conf")]
[InlineData("--paths /windows/system32")]
[InlineData("-")]
public void Optionsartige_Eingaben_werden_abgelehnt(string input)
public void Option_like_inputs_are_rejected(string input)
{
YouTubeUrl.TryResolveChannelUrl(input, out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
@@ -37,7 +37,7 @@ public sealed class YouTubeUrlTests
[InlineData("http://192.168.178.10:8418/Richard/ClawdDotNet.git")]
[InlineData("file:///C:/Windows/win.ini")]
[InlineData("ftp://example.com/datei")]
public void Fremde_Hosts_und_Schemata_werden_abgelehnt(string url)
public void Foreign_hosts_and_schemes_are_rejected(string url)
{
YouTubeUrl.TryResolveChannelUrl(url, out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
@@ -51,7 +51,7 @@ public sealed class YouTubeUrlTests
[InlineData("kanal&name")]
[InlineData("../../etc/passwd")]
[InlineData("@kanal/../../evil")]
public void Unsaubere_Handles_werden_abgelehnt(string input)
public void Unclean_handles_are_rejected(string input)
{
YouTubeUrl.TryResolveChannelUrl(input, out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
@@ -61,7 +61,7 @@ public sealed class YouTubeUrlTests
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Leere_Eingaben_werden_abgelehnt(string? input)
public void Empty_inputs_are_rejected(string? input)
{
YouTubeUrl.TryResolveChannelUrl(input, out _, out _).ShouldBeFalse();
}
@@ -74,7 +74,7 @@ public sealed class YouTubeUrlTests
[InlineData("@Computerphile", "https://www.youtube.com/@Computerphile/videos")]
[InlineData("Computerphile", "https://www.youtube.com/@Computerphile/videos")]
[InlineData("kanal_mit-punkt.name", "https://www.youtube.com/@kanal_mit-punkt.name/videos")]
public void Handles_werden_zu_Kanal_URLs(string input, string expected)
public void Handles_become_channel_URLs(string input, string expected)
{
YouTubeUrl.TryResolveChannelUrl(input, out var url, out var error).ShouldBeTrue(error);
url.ShouldBe(expected);
@@ -86,7 +86,7 @@ public sealed class YouTubeUrlTests
[InlineData("https://m.youtube.com/watch?v=abc")]
[InlineData("https://music.youtube.com/watch?v=abc")]
[InlineData("https://youtu.be/dQw4w9WgXcQ")]
public void Echte_YouTube_URLs_werden_durchgelassen(string url)
public void Real_YouTube_URLs_are_let_through(string url)
{
YouTubeUrl.TryResolveChannelUrl(url, out var resolved, out var error).ShouldBeTrue(error);
resolved.ShouldBe(url);
@@ -97,7 +97,7 @@ public sealed class YouTubeUrlTests
// ═══════════════════════════════════════════════════════════
[Fact]
public void Die_URL_steht_hinter_dem_Optionsende_Trenner()
public void The_URL_comes_after_the_end_of_options_separator()
{
// "--" ends the option list. Even if a value looked like an option,
// yt-dlp would treat it as an address after that.
@@ -110,7 +110,7 @@ public sealed class YouTubeUrlTests
}
[Fact]
public void Jedes_Argument_ist_ein_eigener_Eintrag()
public void Every_argument_is_its_own_entry()
{
// Crucial: no entry may contain several arguments — otherwise they could
// be split apart again at startup.
@@ -128,7 +128,7 @@ public sealed class YouTubeUrlTests
}
[Fact]
public void Ohne_ffmpeg_entfaellt_die_Konvertierung()
public void Without_ffmpeg_the_conversion_is_skipped()
{
var args = YouTubeUrl.BuildAudioDownloadArgs(
"/tmp/x.%(ext)s", "https://youtu.be/abc", null, convertToMp3: false);
@@ -46,10 +46,10 @@ public sealed class BudgetRequestTests : IDisposable
[Fact]
public void A_special_budget_always_needs_a_human()
{
FreigabeRegeln.IstImmerFreigabe(CaseDeskToolName, "request_budget").ShouldBeTrue();
ApprovalRules.AlwaysNeedsApproval(CaseDeskToolName, "request_budget").ShouldBeTrue();
// Not even a tool configuration that allows it, or the tool's own default, lifts it.
FreigabeRegeln.Einstufen(CaseDeskToolName, "request_budget", Werkzeugrechte.Keine, StagingDecision.Auto, new StagingPolicy())
ApprovalRules.Classify(CaseDeskToolName, "request_budget", ToolRights.Keine, StagingDecision.Auto, new StagingPolicy())
.Entscheidung.ShouldBe(StagingDecision.Approve);
}
@@ -17,7 +17,7 @@ namespace ClawdDotNet.Tools.Tests.Taskboard;
/// again at every run what was already open from the previous one — over thirty tasks
/// for @human, among them the same price finding six times.
/// </summary>
public sealed class DublettenTests : IDisposable
public sealed class DuplicateTests : IDisposable
{
private readonly string _verzeichnis;
private readonly SqliteStorage _storage;
@@ -25,7 +25,7 @@ public sealed class DublettenTests : IDisposable
private readonly TaskboardService _board;
private readonly TaskboardTool _tool = new();
public DublettenTests()
public DuplicateTests()
{
_verzeichnis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_verzeichnis, "state.db"));
@@ -52,15 +52,15 @@ public sealed class DublettenTests : IDisposable
private Task<ToolResult> Run(string json)
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
private Task<TaskItem> Anlegen(string titel, TaskItemStatus status = TaskItemStatus.Todo)
=> _board.CreateAsync(new TaskItem { Title = titel, Status = status, Assignee = "@human", Body = "Beobachtet." }, default);
private Task<TaskItem> Create(string title, TaskItemStatus status = TaskItemStatus.Todo)
=> _board.CreateAsync(new TaskItem { Title = title, Status = status, Assignee = "@human", Body = "Beobachtet." }, default);
// ─── task_find ───
[Fact]
public async Task Ohne_Treffer_sagt_die_Antwort_dass_angelegt_werden_darf()
public async Task Without_matches_the_answer_says_creating_is_allowed()
{
await Anlegen("Betriebsluecke: Wetter Warnungen veraltet");
await Create("Betriebsluecke: Wetter Warnungen veraltet");
var result = await Run("""{"action":"task_find","title":"Betriebsluecke: Eur Kurse nicht aktuell"}""");
@@ -70,21 +70,21 @@ public sealed class DublettenTests : IDisposable
}
[Fact]
public async Task Der_gleiche_Befund_wird_mit_Id_Alter_und_Einstufung_gefunden()
public async Task The_same_finding_is_found_with_id_age_and_classification()
{
var vorhanden = await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell");
var present = await Create("Betriebsluecke: Eur Kurse nicht aktuell");
var result = await Run("""{"action":"task_find","title":"Betriebsluecke: Kurse EUR nicht aktuell"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain(vorhanden.Id);
result.Content.ShouldContain(present.Id);
result.Content.ShouldContain("same issue");
result.Content.ShouldContain("open since");
result.Content.ShouldContain("task_comment", Case.Sensitive, "der Agent soll ergaenzen, nicht neu anlegen");
}
[Fact]
public async Task Ohne_Titel_fragt_das_Werkzeug_nach()
public async Task Without_title_the_tool_asks()
{
var result = await Run("""{"action":"task_find"}""");
@@ -95,14 +95,14 @@ public sealed class DublettenTests : IDisposable
// ─── the brake when creating ───
[Fact]
public async Task Eine_zweite_gleichlautende_Aufgabe_wird_nicht_angelegt()
public async Task A_second_identical_task_is_not_created()
{
var erste = await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell");
var first = await Create("Betriebsluecke: Eur Kurse nicht aktuell");
var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Kurse EUR nicht aktuell","assignee":"@human"}""");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain(erste.Id);
result.ErrorMessage.ShouldNotBeNull().ShouldContain(first.Id);
result.ErrorMessage.ShouldContain("recorded");
(await _board.ListAsync(new TaskQuery { Limit = 50 }, default)).Count
@@ -110,21 +110,21 @@ public sealed class DublettenTests : IDisposable
}
[Fact]
public async Task Das_erneute_Auftreten_steht_danach_an_der_bestehenden_Aufgabe()
public async Task The_recurrence_is_then_on_the_existing_task()
{
var erste = await Anlegen("Betriebsluecke: Nachrichten fehlen");
var first = await Create("Betriebsluecke: Nachrichten fehlen");
await Run("""{"action":"task_create","title":"Betriebsluecke: Nachrichten fehlen","assignee":"@human"}""");
var datei = await File.ReadAllTextAsync(Path.Combine(_board.TasksDirectory, (await _board.GetAsync(erste.Id, default))!.FileName));
var datei = await File.ReadAllTextAsync(Path.Combine(_board.TasksDirectory, (await _board.GetAsync(first.Id, default))!.FileName));
datei.ShouldContain("Observed again");
datei.ShouldContain("hausmeister", Case.Insensitive);
}
[Fact]
public async Task Ein_anderer_Sachverhalt_kommt_ganz_normal_durch()
public async Task A_different_matter_comes_through_normally()
{
await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell");
await Create("Betriebsluecke: Eur Kurse nicht aktuell");
var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Wetter Warnungen stand.md veraltet","assignee":"@human"}""");
@@ -133,9 +133,9 @@ public sealed class DublettenTests : IDisposable
}
[Fact]
public async Task Mit_force_darf_der_Agent_trotzdem_anlegen()
public async Task With_force_the_agent_may_create_anyway()
{
await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell");
await Create("Betriebsluecke: Eur Kurse nicht aktuell");
var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Eur Kurse nicht aktuell","assignee":"@human","force":true}""");
@@ -144,11 +144,11 @@ public sealed class DublettenTests : IDisposable
}
[Fact]
public async Task Die_Dublette_wird_auch_hinter_500_anderen_Aufgaben_gefunden()
public async Task The_duplicate_is_found_even_behind_500_other_tasks()
{
// #716: The search read ListAsync with a limit of 500, sorted by priority. An old
// task with low priority thus fell out of sight as soon as the board grew.
var alt = await _board.CreateAsync(new TaskItem
var old = await _board.CreateAsync(new TaskItem
{
Title = "Betriebsluecke: Eur Kurse nicht aktuell", Assignee = "@human", Priority = 1
}, default);
@@ -158,13 +158,13 @@ public sealed class DublettenTests : IDisposable
var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Kurse EUR nicht aktuell","assignee":"@human"}""");
result.Success.ShouldBeFalse("die alte Aufgabe meint dasselbe");
result.ErrorMessage.ShouldNotBeNull().ShouldContain(alt.Id);
result.ErrorMessage.ShouldNotBeNull().ShouldContain(old.Id);
}
[Fact]
public async Task Eine_erledigte_Aufgabe_blockiert_den_naechsten_Befund_nicht()
public async Task A_done_task_does_not_block_the_next_finding()
{
await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell", TaskItemStatus.Done);
await Create("Betriebsluecke: Eur Kurse nicht aktuell", TaskItemStatus.Done);
var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Eur Kurse nicht aktuell","assignee":"@human"}""");
@@ -8,11 +8,11 @@ namespace ClawdDotNet.Tools.Tests.WebFetch;
/// Stage 1 of WebFetch (#602): main content as compact Markdown instead of the whole page as
/// running text. The sample pages are self-written and reproduce typical structures.
/// </summary>
public sealed class HtmlExtraktorTests
public sealed class HtmlExtractorTests
{
private static readonly Uri Basis = new("https://www.beispiel.de/politik/artikel-123.html");
private const string Artikelseite = """
private const string ArticlePage = """
<!DOCTYPE html>
<html lang="de">
<head>
@@ -53,23 +53,23 @@ public sealed class HtmlExtraktorTests
""";
[Fact]
public void Artikel_liefert_den_Hauptinhalt_ohne_Navigation_Cookiehinweis_und_Skripte()
public void Article_returns_main_content_without_navigation_cookie_notice_and_scripts()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis);
var content = HtmlExtractor.Extract(ArticlePage, Basis);
inhalt.Bereich.ShouldBe("article");
inhalt.Markdown.ShouldContain("Der Stadtrat hat am Montag");
inhalt.Markdown.ShouldNotContain("Wirtschaft");
inhalt.Markdown.ShouldNotContain("Cookies");
inhalt.Markdown.ShouldNotContain("GEHEIMER-SKRIPTTEXT");
inhalt.Markdown.ShouldNotContain("Impressum");
inhalt.Markdown.ShouldNotContain("Zehn Tipps");
content.Bereich.ShouldBe("article");
content.Markdown.ShouldContain("Der Stadtrat hat am Montag");
content.Markdown.ShouldNotContain("Wirtschaft");
content.Markdown.ShouldNotContain("Cookies");
content.Markdown.ShouldNotContain("GEHEIMER-SKRIPTTEXT");
content.Markdown.ShouldNotContain("Impressum");
content.Markdown.ShouldNotContain("Zehn Tipps");
}
[Fact]
public void Struktur_bleibt_als_Markdown_erhalten()
public void Structure_is_kept_as_markdown()
{
var md = HtmlExtraktor.Extrahiere(Artikelseite, Basis).Markdown;
var md = HtmlExtractor.Extract(ArticlePage, Basis).Markdown;
md.ShouldContain("# Stadtrat beschliesst Radweg");
md.ShouldContain("## Kosten und Zeitplan");
@@ -82,84 +82,84 @@ public sealed class HtmlExtraktorTests
}
[Fact]
public void Die_Kopfzeile_des_Artikels_ist_Inhalt_nicht_Seitenrahmen()
=> HtmlExtraktor.Extrahiere(Artikelseite, Basis).Markdown.ShouldContain("Von Erika Beispiel");
public void The_article_header_is_content_not_page_frame()
=> HtmlExtractor.Extract(ArticlePage, Basis).Markdown.ShouldContain("Von Erika Beispiel");
[Fact]
public void Mit_Links_werden_relative_Ziele_absolut()
=> HtmlExtraktor.Extrahiere(Artikelseite, Basis, mitLinks: true).Markdown
public void With_links_relative_targets_become_absolute()
=> HtmlExtractor.Extract(ArticlePage, Basis, withLinks: true).Markdown
.ShouldContain("[Beschlussvorlage](https://www.beispiel.de/dokumente/beschluss.pdf)");
[Fact]
public void Ohne_Wunsch_nach_Links_bleibt_nur_der_Linktext()
public void Without_asking_for_links_only_the_link_text_stays()
{
var md = HtmlExtraktor.Extrahiere(Artikelseite, Basis).Markdown;
var md = HtmlExtractor.Extract(ArticlePage, Basis).Markdown;
md.ShouldContain("Laut Beschlussvorlage kostet");
md.ShouldNotContain("https://www.beispiel.de/dokumente");
}
[Fact]
public void Uebersichtsseite_mit_vielen_Teaser_Artikeln_nimmt_den_ganzen_Hauptbereich()
public void Overview_page_with_many_teaser_articles_takes_the_whole_main_area()
{
var teaser = string.Concat(Enumerable.Range(1, 6).Select(i =>
$"<article><h2>Meldung {i}</h2><p>Kurzfassung der Meldung {i} mit so viel Text, dass die Kachel fuer sich allein "
+ "die Mindestlaenge fuer einen Bereich erreicht und damit als Kandidat in Frage kaeme, obwohl sie nur einer von vielen ist.</p></article>"));
var html = $"<html><body><nav>Menue</nav><main>{teaser}</main></body></html>";
var inhalt = HtmlExtraktor.Extrahiere(html, Basis);
var content = HtmlExtractor.Extract(html, Basis);
inhalt.Bereich.ShouldBe("main");
inhalt.Markdown.ShouldContain("## Meldung 1");
inhalt.Markdown.ShouldContain("## Meldung 6");
content.Bereich.ShouldBe("main");
content.Markdown.ShouldContain("## Meldung 1");
content.Markdown.ShouldContain("## Meldung 6");
}
[Fact]
public void Metadaten_werden_mitgeliefert()
public void Metadata_is_included()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis);
var content = HtmlExtractor.Extract(ArticlePage, Basis);
inhalt.Titel.ShouldBe("Stadtrat beschliesst Radweg");
inhalt.Sprache.ShouldBe("de");
inhalt.KanonischeUrl.ShouldBe("https://www.beispiel.de/politik/radweg-123.html");
inhalt.Veroeffentlicht.ShouldBe(new DateTimeOffset(2026, 9, 14, 8, 30, 0, TimeSpan.FromHours(2)));
content.Titel.ShouldBe("Stadtrat beschliesst Radweg");
content.Sprache.ShouldBe("de");
content.KanonischeUrl.ShouldBe("https://www.beispiel.de/politik/radweg-123.html");
content.Veroeffentlicht.ShouldBe(new DateTimeOffset(2026, 9, 14, 8, 30, 0, TimeSpan.FromHours(2)));
}
[Fact]
public void Der_Markdown_Text_ist_deutlich_kuerzer_als_die_Seite()
=> HtmlExtraktor.Extrahiere(Artikelseite, Basis).Markdown.Length.ShouldBeLessThan(Artikelseite.Length / 2);
public void The_markdown_text_is_much_shorter_than_the_page()
=> HtmlExtractor.Extract(ArticlePage, Basis).Markdown.Length.ShouldBeLessThan(ArticlePage.Length / 2);
[Fact]
public void Ein_Selektor_holt_gezielt_nur_den_Teil()
public void A_selector_fetches_only_that_part()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis, "table");
var content = HtmlExtractor.Extract(ArticlePage, Basis, "table");
inhalt.Bereich.ShouldBe("selector");
inhalt.Markdown.ShouldStartWith("| Abschnitt | Länge |");
inhalt.Markdown.ShouldNotContain("Stadtrat");
inhalt.Hinweis.ShouldBeNull();
content.Bereich.ShouldBe("selector");
content.Markdown.ShouldStartWith("| Abschnitt | Länge |");
content.Markdown.ShouldNotContain("Stadtrat");
content.Hinweis.ShouldBeNull();
}
[Fact]
public void Ein_Selektor_ohne_Treffer_faellt_mit_Hinweis_auf_den_Hauptinhalt_zurueck()
public void A_selector_without_matches_falls_back_to_the_main_content_with_a_hint()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis, "#gibt-es-nicht");
var content = HtmlExtractor.Extract(ArticlePage, Basis, "#gibt-es-nicht");
inhalt.Bereich.ShouldBe("article");
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("matched nothing");
content.Bereich.ShouldBe("article");
content.Hinweis.ShouldNotBeNull().ShouldContain("matched nothing");
}
[Fact]
public void Ein_ungueltiger_Selektor_bringt_das_Werkzeug_nicht_zum_Absturz()
public void An_invalid_selector_does_not_crash_the_tool()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis, "div[[[");
var content = HtmlExtractor.Extract(ArticlePage, Basis, "div[[[");
inhalt.Markdown.ShouldContain("Der Stadtrat hat am Montag");
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("invalid");
content.Markdown.ShouldContain("Der Stadtrat hat am Montag");
content.Hinweis.ShouldNotBeNull().ShouldContain("invalid");
}
[Fact]
public void Ohne_article_und_main_findet_die_Textdichte_den_Inhaltsblock()
public void Without_article_and_main_text_density_finds_the_content_block()
{
const string html = """
<html><body>
@@ -174,16 +174,16 @@ public sealed class HtmlExtraktorTests
</body></html>
""";
var inhalt = HtmlExtraktor.Extrahiere(html, Basis);
var content = HtmlExtractor.Extract(html, Basis);
inhalt.Bereich.ShouldBe("hauptinhalt");
inhalt.Markdown.ShouldContain("Messstation im Stadtpark");
inhalt.Markdown.ShouldNotContain("Pollenkalender");
inhalt.Markdown.ShouldNotContain("Gamma");
content.Bereich.ShouldBe("hauptinhalt");
content.Markdown.ShouldContain("Messstation im Stadtpark");
content.Markdown.ShouldNotContain("Pollenkalender");
content.Markdown.ShouldNotContain("Gamma");
}
[Fact]
public void Verschachtelte_Listen_behalten_ihren_Einzug()
public void Nested_lists_keep_their_indent()
{
const string html = """
<html><body><main>
@@ -192,44 +192,44 @@ public sealed class HtmlExtraktorTests
</main></body></html>
""";
var md = HtmlExtraktor.Extrahiere(html, Basis).Markdown;
var md = HtmlExtractor.Extract(html, Basis).Markdown;
md.ShouldContain("1. Erster Schritt\n - Unterpunkt A\n - Unterpunkt B\n2. Zweiter Schritt");
}
[Fact]
public void Volltext_enthaelt_alles_Sichtbare_ausser_Skripten()
public void Full_text_contains_everything_visible_except_scripts()
{
var voll = HtmlExtraktor.Volltext(Artikelseite);
var full = HtmlExtractor.FullText(ArticlePage);
voll.ShouldContain("Wirtschaft");
voll.ShouldContain("Der Stadtrat hat am Montag");
voll.ShouldNotContain("GEHEIMER-SKRIPTTEXT");
full.ShouldContain("Wirtschaft");
full.ShouldContain("Der Stadtrat hat am Montag");
full.ShouldNotContain("GEHEIMER-SKRIPTTEXT");
}
}
/// <summary>What arrives at the model for which response (#602).</summary>
public sealed class WebInhaltTests
public sealed class WebContentTests
{
private static readonly Uri Quelle = new("https://api.beispiel.de/v1/wetter");
private static readonly Uri Source = new("https://api.beispiel.de/v1/wetter");
[Theory]
[InlineData("application/json")]
[InlineData("text/html")] // servers that deliver JSON as HTML do exist
[InlineData(null)]
public void Json_bleibt_unveraendert(string? mediaType)
public void Json_stays_unchanged(string? mediaType)
{
const string json = """{"current":{"time":"2026-09-15T12:00","temperature_2m":18.4}}""";
var inhalt = WebInhalt.Aufbereiten(json, mediaType, Quelle, AbrufModus.Auto, null);
var content = WebContent.Prepare(json, mediaType, Source, FetchMode.Auto, null);
inhalt.Art.ShouldBe("json");
inhalt.Bereich.ShouldBe("roh");
inhalt.Text.ShouldBe(json);
content.Art.ShouldBe("json");
content.Bereich.ShouldBe("roh");
content.Text.ShouldBe(json);
}
[Fact]
public void Zu_wenig_Hauptinhalt_faellt_mit_Hinweis_auf_den_Volltext_zurueck()
public void Too_little_main_content_falls_back_to_the_full_text_with_a_hint()
{
// Overview page: the content is a link list in the navigation, the body only holds a
// placeholder that JavaScript would fill. The extraction rightly removes the navigation
@@ -238,48 +238,48 @@ public sealed class WebInhaltTests
+ string.Concat(Enumerable.Range(1, 60).Select(i => $"<li><a href=\"/rubrik/{i}\">Rubrik {i}</a></li>"))
+ "</ul></nav><div id=\"app\">Lade …</div></body></html>";
var inhalt = WebInhalt.Aufbereiten(html, "text/html", Quelle, AbrufModus.Auto, null);
var content = WebContent.Prepare(html, "text/html", Source, FetchMode.Auto, null);
inhalt.Bereich.ShouldBe("vollstaendig");
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("JavaScript");
content.Bereich.ShouldBe("vollstaendig");
content.Hinweis.ShouldNotBeNull().ShouldContain("JavaScript");
}
[Fact]
public void Modus_voll_liefert_den_ganzen_Text()
public void Mode_full_returns_the_whole_text()
{
const string html = "<html><body><nav>Menue</nav><article><p>Inhalt</p></article></body></html>";
var inhalt = WebInhalt.Aufbereiten(html, "text/html", Quelle, AbrufModus.Voll, null);
var content = WebContent.Prepare(html, "text/html", Source, FetchMode.Voll, null);
inhalt.Bereich.ShouldBe("vollstaendig");
inhalt.Text.ShouldContain("Menue");
content.Bereich.ShouldBe("vollstaendig");
content.Text.ShouldContain("Menue");
}
[Fact]
public void Lange_Inhalte_kommen_in_Abschnitten_die_zusammen_den_ganzen_Text_ergeben()
public void Long_content_comes_in_sections_that_together_form_the_whole_text()
{
var text = string.Join("\n\n", Enumerable.Range(1, 400).Select(i => $"Absatz {i}: " + new string('x', 60)));
var teile = new List<string>();
var parts = new List<string>();
int? offset = 0;
while (offset is not null)
{
var inhalt = WebInhalt.Aufbereiten(text, "text/plain", Quelle, AbrufModus.Auto, null, offset.Value, 5_000);
teile.Add(inhalt.Text);
if (inhalt.NaechsterOffset is not null)
inhalt.Hinweis.ShouldNotBeNull().ShouldContain($"offset={inhalt.NaechsterOffset}");
offset = inhalt.NaechsterOffset;
var content = WebContent.Prepare(text, "text/plain", Source, FetchMode.Auto, null, offset.Value, 5_000);
parts.Add(content.Text);
if (content.NaechsterOffset is not null)
content.Hinweis.ShouldNotBeNull().ShouldContain($"offset={content.NaechsterOffset}");
offset = content.NaechsterOffset;
}
teile.Count.ShouldBeGreaterThan(1);
string.Concat(teile).ShouldBe(text);
teile[0].ShouldEndWith("\n\n", customMessage: "an einer Absatzgrenze geschnitten");
parts.Count.ShouldBeGreaterThan(1);
string.Concat(parts).ShouldBe(text);
parts[0].ShouldEndWith("\n\n", customMessage: "an einer Absatzgrenze geschnitten");
}
[Fact]
public void Umlaute_werden_in_der_Antwort_nicht_escaped()
public void Umlauts_are_not_escaped_in_the_answer()
{
var json = JsonSerializer.Serialize(new { text = "Grüße aus Köln – 18 °C" }, WebInhalt.JsonOptionen);
var json = JsonSerializer.Serialize(new { text = "Grüße aus Köln – 18 °C" }, WebContent.JsonOptionen);
json.ShouldContain("Grüße aus Köln – 18 °C");
json.ShouldNotContain("\\u00", Case.Sensitive);
@@ -290,6 +290,6 @@ public sealed class WebInhaltTests
[InlineData("text/csv", "a,b", "text")]
[InlineData(null, "<html><body>x</body></html>", "html")]
[InlineData(null, "<?xml version=\"1.0\"?><feed/>", "xml")]
public void Die_Art_des_Inhalts_wird_erkannt(string? mediaType, string body, string erwartet)
=> WebInhalt.Art(mediaType, body).ShouldBe(erwartet);
public void The_kind_of_content_is_recognised(string? mediaType, string body, string expected)
=> WebContent.KindOf(mediaType, body).ShouldBe(expected);
}
@@ -11,78 +11,78 @@ namespace ClawdDotNet.Tools.Tests.WebFetch;
/// </summary>
public sealed class JsonFilterTests
{
private static readonly Uri Quelle = new("https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json");
private static readonly Uri Source = new("https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json");
private static string DwdAehnlich(int fuellWarnungen = 0)
private static string DwdLike(int fillWarnings = 0)
{
var fuell = string.Join(",", Enumerable.Range(1, fuellWarnungen).Select(i =>
var fill = string.Join(",", Enumerable.Range(1, fillWarnings).Select(i =>
$$"""{"regionName":"Kreis {{i}}","event":"WINDBÖEN","level":2,"start":1726500000000}"""));
return $$"""
warnWetter.loadWarnings({"time":1726650000000,"warnings":{
"111000000":[{"regionName":"Berlin","event":"STARKREGEN","level":3,"start":1726650000000}],
"105000000":[{"regionName":"Hamburg","event":"FROST","level":1,"start":1726650000000}{{(fuell.Length > 0 ? "," + fuell : "")}}]
"105000000":[{"regionName":"Hamburg","event":"FROST","level":1,"start":1726650000000}{{(fill.Length > 0 ? "," + fill : "")}}]
},"vorabInformation":{},"copyright":"DWD"});
""";
}
[Fact]
public void JSONP_wird_ausgepackt_und_nur_die_passende_Warnung_geliefert()
public void JSONP_is_unwrapped_and_only_the_matching_warning_returned()
{
var inhalt = WebInhalt.Aufbereiten(DwdAehnlich(), "application/javascript", Quelle, AbrufModus.Auto, null, filter: "berlin");
var content = WebContent.Prepare(DwdLike(), "application/javascript", Source, FetchMode.Auto, null, filter: "berlin");
inhalt.Art.ShouldBe("json");
inhalt.Bereich.ShouldBe("gefiltert");
using var doc = JsonDocument.Parse(inhalt.Text);
content.Art.ShouldBe("json");
content.Bereich.ShouldBe("gefiltert");
using var doc = JsonDocument.Parse(content.Text);
doc.RootElement.GetProperty("treffer").GetInt32().ShouldBe(1);
var eintrag = doc.RootElement.GetProperty("eintraege")[0];
eintrag.GetProperty("pfad").GetString().ShouldBe("$.warnings.111000000[0]");
eintrag.GetProperty("wert").GetProperty("event").GetString().ShouldBe("STARKREGEN");
inhalt.Text.ShouldNotContain("Hamburg");
var entry = doc.RootElement.GetProperty("eintraege")[0];
entry.GetProperty("pfad").GetString().ShouldBe("$.warnings.111000000[0]");
entry.GetProperty("wert").GetProperty("event").GetString().ShouldBe("STARKREGEN");
content.Text.ShouldNotContain("Hamburg");
}
[Fact]
public void Ein_passender_Schluesselname_liefert_seinen_Wert()
public void A_matching_key_name_returns_its_value()
{
var inhalt = WebInhalt.Aufbereiten(DwdAehnlich(), "application/json", Quelle, AbrufModus.Auto, null, filter: "1110");
var content = WebContent.Prepare(DwdLike(), "application/json", Source, FetchMode.Auto, null, filter: "1110");
inhalt.Text.ShouldContain("STARKREGEN");
inhalt.Text.ShouldNotContain("FROST");
content.Text.ShouldContain("STARKREGEN");
content.Text.ShouldNotContain("FROST");
}
[Fact]
public void Das_gefilterte_Ergebnis_ist_ein_Bruchteil_der_ganzen_Antwort()
public void The_filtered_result_is_a_fraction_of_the_whole_answer()
{
var gross = DwdAehnlich(fuellWarnungen: 800);
var inhalt = WebInhalt.Aufbereiten(gross, "application/json", Quelle, AbrufModus.Auto, null, filter: "Berlin");
var large = DwdLike(fillWarnings: 800);
var content = WebContent.Prepare(large, "application/json", Source, FetchMode.Auto, null, filter: "Berlin");
gross.Length.ShouldBeGreaterThan(60_000);
inhalt.Text.Length.ShouldBeLessThan(400);
inhalt.NaechsterOffset.ShouldBeNull();
large.Length.ShouldBeGreaterThan(60_000);
content.Text.Length.ShouldBeLessThan(400);
content.NaechsterOffset.ShouldBeNull();
}
[Fact]
public void Ohne_Treffer_sagt_der_Hinweis_das_ausdruecklich()
public void Without_matches_the_hint_says_so_explicitly()
{
var inhalt = WebInhalt.Aufbereiten(DwdAehnlich(), "application/json", Quelle, AbrufModus.Auto, null, filter: "Muenchen");
var content = WebContent.Prepare(DwdLike(), "application/json", Source, FetchMode.Auto, null, filter: "Muenchen");
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("No entries");
content.Hinweis.ShouldNotBeNull().ShouldContain("No entries");
}
[Fact]
public void Kein_gueltiges_JSON_wird_ungefiltert_mit_Hinweis_geliefert()
public void Invalid_JSON_is_returned_unfiltered_with_a_hint()
{
var inhalt = WebInhalt.Aufbereiten("Das ist Text, kein JSON.", "text/plain", Quelle, AbrufModus.Auto, null, filter: "Berlin");
var content = WebContent.Prepare("Das ist Text, kein JSON.", "text/plain", Source, FetchMode.Auto, null, filter: "Berlin");
inhalt.Text.ShouldBe("Das ist Text, kein JSON.");
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("not valid JSON");
content.Text.ShouldBe("Das ist Text, kein JSON.");
content.Hinweis.ShouldNotBeNull().ShouldContain("not valid JSON");
}
[Fact]
public void Blaettert_ein_Agent_durch_grosses_JSON_empfiehlt_der_Hinweis_den_filter()
public void If_an_agent_pages_through_large_JSON_the_hint_recommends_the_filter()
{
var inhalt = WebInhalt.Aufbereiten(DwdAehnlich(fuellWarnungen: 800), "application/json", Quelle, AbrufModus.Auto, null);
var content = WebContent.Prepare(DwdLike(fillWarnings: 800), "application/json", Source, FetchMode.Auto, null);
inhalt.NaechsterOffset.ShouldNotBeNull();
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("filter");
content.NaechsterOffset.ShouldNotBeNull();
content.Hinweis.ShouldNotBeNull().ShouldContain("filter");
}
}
@@ -10,10 +10,10 @@ namespace ClawdDotNet.Tools.Tests.WebFetch;
/// and resolves to 127.0.0.1 - in the test run WebFetch thus opened a connection
/// to loopback. That's why the resolved address is checked, without a real network.
/// </summary>
public sealed class UrlGuardAufloesungTests
public sealed class UrlGuardResolutionTests
{
private static Func<string, CancellationToken, Task<IPAddress[]>> Aufloesung(params string[] adressen)
=> (_, _) => Task.FromResult(adressen.Select(IPAddress.Parse).ToArray());
private static Func<string, CancellationToken, Task<IPAddress[]>> Resolution(params string[] addresses)
=> (_, _) => Task.FromResult(addresses.Select(IPAddress.Parse).ToArray());
[Theory]
[InlineData("127.0.0.1")]
@@ -21,47 +21,47 @@ public sealed class UrlGuardAufloesungTests
[InlineData("169.254.169.254")]
[InlineData("::1")]
[InlineData("::ffff:10.0.0.5")]
public async Task Ein_Name_der_ins_lokale_Netz_aufloest_wird_gesperrt(string adresse)
public async Task A_name_resolving_into_the_local_network_is_blocked(string address)
{
var ex = await Should.ThrowAsync<HttpRequestException>(
() => UrlGuard.ResolveAllowedAsync("localtest.me", Aufloesung(adresse), default));
() => UrlGuard.ResolveAllowedAsync("localtest.me", Resolution(address), default));
ex.Message.ShouldContain("localtest.me");
ex.Message.ShouldContain("blocked");
}
[Fact]
public async Task Ein_Name_mit_oeffentlicher_und_interner_Adresse_wird_gesperrt()
public async Task A_name_with_a_public_and_an_internal_address_is_blocked()
{
// Exactly the pattern with which you outwit a sampling check.
await Should.ThrowAsync<HttpRequestException>(
() => UrlGuard.ResolveAllowedAsync("gemischt.example", Aufloesung("93.184.215.14", "127.0.0.1"), default));
() => UrlGuard.ResolveAllowedAsync("gemischt.example", Resolution("93.184.215.14", "127.0.0.1"), default));
}
[Fact]
public async Task Ein_Name_mit_oeffentlichen_Adressen_wird_zugelassen()
public async Task A_name_with_public_addresses_is_allowed()
{
var adressen = await UrlGuard.ResolveAllowedAsync(
"example.com", Aufloesung("93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"), default);
var addresses = await UrlGuard.ResolveAllowedAsync(
"example.com", Resolution("93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"), default);
adressen.Length.ShouldBe(2);
addresses.Length.ShouldBe(2);
}
[Fact]
public async Task Ein_IP_Literal_wird_ohne_Aufloesung_geprueft()
public async Task An_IP_literal_is_checked_without_resolution()
{
var aufgerufen = false;
var called = false;
await Should.ThrowAsync<HttpRequestException>(() => UrlGuard.ResolveAllowedAsync(
"[::1]",
(_, _) => { aufgerufen = true; return Task.FromResult(Array.Empty<IPAddress>()); },
(_, _) => { called = true; return Task.FromResult(Array.Empty<IPAddress>()); },
default));
aufgerufen.ShouldBeFalse();
called.ShouldBeFalse();
}
[Fact]
public async Task Ein_Name_ohne_Adresse_wird_nicht_verbunden()
public async Task A_name_without_address_is_not_connected()
=> await Should.ThrowAsync<HttpRequestException>(
() => UrlGuard.ResolveAllowedAsync("nirgends.example", Aufloesung(), default));
() => UrlGuard.ResolveAllowedAsync("nirgends.example", Resolution(), default));
}
@@ -33,7 +33,7 @@ public sealed class UrlGuardTests
[InlineData("http://[fd00::1]/")]
[InlineData("http://nas.local/")]
[InlineData("http://dienst.internal/")]
public void Adressen_im_lokalen_oder_privaten_Netz_werden_gesperrt(string url)
public void Addresses_in_the_local_or_private_network_are_blocked(string url)
{
Validate(url, out var error).ShouldBeFalse($"'{url}' muss gesperrt sein");
error.ShouldNotBeNull();
@@ -44,7 +44,7 @@ public sealed class UrlGuardTests
[InlineData("172.32.0.1")] // just above
[InlineData("11.0.0.1")]
[InlineData("8.8.8.8")]
public void Oeffentliche_Adressen_gelten_nicht_als_privat(string host)
public void Public_addresses_do_not_count_as_private(string host)
{
UrlGuard.IsPrivateOrLocal(host).ShouldBeFalse();
}
@@ -57,7 +57,7 @@ public sealed class UrlGuardTests
[InlineData("file:///C:/Windows/win.ini")]
[InlineData("ftp://example.com/datei")]
[InlineData("gopher://example.com/")]
public void Nur_http_und_https_sind_erlaubt(string url)
public void Only_http_and_https_are_allowed(string url)
{
Validate(url, out _).ShouldBeFalse();
}
@@ -72,7 +72,7 @@ public sealed class UrlGuardTests
[InlineData("https://news.example.com/artikel")]
[InlineData("https://a.b.example.com/x")]
[InlineData("https://reuters.com/")]
public void Erlaubte_Domains_und_ihre_Subdomains_gehen_durch(string url)
public void Allowed_domains_and_their_subdomains_go_through(string url)
{
Validate(url, out var error).ShouldBeTrue(error);
}
@@ -82,13 +82,13 @@ public sealed class UrlGuardTests
[InlineData("https://notexample.com/x")]
[InlineData("https://evil.com/x")]
[InlineData("https://exampleXcom/x")]
public void Aehnlich_aussehende_Domains_werden_abgelehnt(string url)
public void Lookalike_domains_are_rejected(string url)
{
Validate(url, out _).ShouldBeFalse();
}
[Fact]
public void Ohne_Whitelist_wird_nichts_durchgelassen()
public void Without_whitelist_nothing_is_let_through()
{
UrlGuard.TryValidate("https://example.com", [], out _, out _).ShouldBeFalse();
}
@@ -98,14 +98,14 @@ public sealed class UrlGuardTests
/// "mywww.example.com" became "myexample.com".
/// </summary>
[Fact]
public void Die_www_Behandlung_verstuemmelt_keine_anderen_Hostnamen()
public void The_www_handling_does_not_mangle_other_host_names()
{
UrlGuard.IsDomainAllowed("mywww.example.com", Allowed).ShouldBeTrue();
UrlGuard.IsDomainAllowed("wwwxexample.com", Allowed).ShouldBeFalse();
}
[Fact]
public void Ein_abschliessender_Punkt_im_Host_umgeht_die_Whitelist_nicht()
public void A_trailing_dot_in_the_host_does_not_bypass_the_whitelist()
{
// "example.com." is the same host in DNS terms.
UrlGuard.IsDomainAllowed("evil.com.", Allowed).ShouldBeFalse();
@@ -113,7 +113,7 @@ public sealed class UrlGuardTests
}
[Fact]
public void Grossschreibung_im_Host_spielt_keine_Rolle()
public void Case_in_the_host_does_not_matter()
{
Validate("https://EXAMPLE.COM/x", out var error).ShouldBeTrue(error);
}
@@ -123,7 +123,7 @@ public sealed class UrlGuardTests
[InlineData("")]
[InlineData("kein-url")]
[InlineData("/nur/ein/pfad")]
public void Ungueltige_Eingaben_werden_abgelehnt(string? url)
public void Invalid_inputs_are_rejected(string? url)
{
UrlGuard.TryValidate(url, Allowed, out _, out _).ShouldBeFalse();
}