Files
ClawdDotNet/tests/ClawdDotNet.Tools.Tests/FileRW/BatchReadTests.cs
T
RichardandClaude Opus 5.5 77062facc9 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>
2026-10-04 13:18:43 +02:00

208 lines
7.8 KiB
C#

using System.Text.Json;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.FileRW;
/// <summary>
/// Batch reading (#663) and the hint about the other workspace (#662).
///
/// Occasion from test operation: the daily close read 24 hourly files individually and died
/// 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 BatchReadTests : IDisposable
{
private readonly string _basis;
private readonly string _personal;
private readonly string _shared;
private readonly FileRWTool _tool = new();
public BatchReadTests()
{
_basis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_personal = Path.Combine(_basis, "Workspace");
_shared = Path.Combine(_basis, "SharedWorkspace");
Directory.CreateDirectory(_personal);
Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden"));
}
public void Dispose()
{
try { Directory.Delete(_basis, recursive: true); }
catch { /* Cleaning up is secondary */ }
}
private AgentToolContext Context(string sharedLevel = "ReadWrite") => new(
"hausmeister", "inst",
new Dictionary<string, object?> { ["sharedAccessLevel"] = sharedLevel },
new NullStateStore(),
NullLogger.Instance,
CancellationToken.None,
WorkspacePath: _personal,
SharedWorkspacePath: _shared);
private Task<ToolResult> Run(string json, string sharedLevel = "ReadWrite")
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(sharedLevel), CancellationToken.None);
private void HourlyFiles(int count, string tag = "2026-09-20")
{
for (var hour = 0; hour < count; hour++)
{
File.WriteAllText(
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 A_directory_with_pattern_comes_in_one_call()
{
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"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("24 of 24 file(s) read");
result.Content.ShouldContain("Temperatur 10 C");
result.Content.ShouldContain("Temperatur 33 C");
result.Content.ShouldNotContain("2026-09-19", Case.Sensitive);
}
[Fact]
public async Task The_files_come_named_and_in_fixed_order()
{
HourlyFiles(3);
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
var sequence = new[] { "00-00.txt", "01-00.txt", "02-00.txt" }
.Select(n => result.Content.IndexOf(n, StringComparison.Ordinal)).ToList();
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 An_explicit_list_is_read_and_missing_ones_named()
{
HourlyFiles(2);
var result = await Run("""
{"action":"read_many","workspace":"shared","paths":[
"wetter/stunden/berlin_2026-09-20_00-00.txt",
"wetter/stunden/berlin_2026-09-20_09-00.txt"]}
""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("1 of 2 file(s) read");
result.Content.ShouldContain("Not found: wetter/stunden/berlin_2026-09-20_09-00.txt");
}
[Fact]
public async Task Too_many_files_are_capped_and_the_rest_named()
{
HourlyFiles(60);
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
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 An_empty_directory_reports_as_an_error()
{
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("No matching file");
}
[Fact]
public async Task Without_read_right_on_the_shared_area_nothing_comes_out()
{
HourlyFiles(2);
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""", sharedLevel: "Denied");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Access denied");
}
[Fact]
public async Task Escaping_the_workspace_is_rejected_here_too()
{
var result = await Run("""{"action":"read_many","workspace":"shared","paths":["../../geheim.txt"]}""");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Access denied");
}
[Fact]
public async Task A_forbidden_file_extension_is_skipped_instead_of_toppling_the_run()
{
HourlyFiles(2);
File.WriteAllText(Path.Combine(_shared, "wetter", "stunden", "skript.exe"), "MZ");
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("2 of 2 file(s) read");
result.Content.ShouldNotContain("skript.exe");
}
// ═══════════════════ #662: hint about the other workspace ═══════════════════
[Fact]
public async Task If_the_file_is_in_the_other_workspace_the_message_says_so()
{
HourlyFiles(1);
var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}""");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("exists in the shared workspace");
result.ErrorMessage.ShouldContain("workspace: \"shared\"", Case.Sensitive);
}
[Fact]
public async Task A_directory_gets_the_hint_too()
{
var result = await Run("""{"action":"list","path":"wetter/stunden"}""");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("exists in the shared workspace");
}
[Fact]
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.
HourlyFiles(1);
var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}""", sharedLevel: "Denied");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldNotContain("exists in");
}
[Fact]
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"}""");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("File not found");
result.ErrorMessage.ShouldNotContain("exists in");
}
}