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>
215 lines
8.4 KiB
C#
215 lines
8.4 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Tools;
|
|
using ClawdDotNet.Tools.FileRW;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Tools.Tests.FileRW;
|
|
|
|
/// <summary>
|
|
/// Sorted, capped listing (#673).
|
|
///
|
|
/// Occasion from test operation: the operations check reported two hourly files as
|
|
/// missing that existed. The directory delivered 105 entries in the order of the
|
|
/// file system — finding two names in that is search work, and a cheap model
|
|
/// does it wrong.
|
|
/// </summary>
|
|
public sealed class DirectoryListTests : IDisposable
|
|
{
|
|
private readonly string _basis;
|
|
private readonly string _personal;
|
|
private readonly string _shared;
|
|
private readonly FileRWTool _tool = new();
|
|
|
|
public DirectoryListTests()
|
|
{
|
|
_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() => new(
|
|
"hausmeister", "inst",
|
|
new Dictionary<string, object?> { ["sharedAccessLevel"] = "ReadWrite" },
|
|
new NullStateStore(),
|
|
NullLogger.Instance,
|
|
CancellationToken.None,
|
|
WorkspacePath: _personal,
|
|
SharedWorkspacePath: _shared);
|
|
|
|
private Task<ToolResult> Run(string json)
|
|
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
|
|
|
/// <summary>Hourly files, deliberately written in mixed order.</summary>
|
|
private void HourlyFiles(int count, string tag = "2026-09-21")
|
|
{
|
|
var verzeichnis = Path.Combine(_shared, "wetter", "stunden");
|
|
foreach (var hour in Enumerable.Range(0, count).OrderBy(_ => Guid.NewGuid()))
|
|
{
|
|
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> Names(string content) => content
|
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(z => z.Trim())
|
|
.Where(z => z.StartsWith("20", StringComparison.Ordinal))
|
|
.Select(z => z.Split(" ", StringSplitOptions.RemoveEmptyEntries).Last())
|
|
.ToList();
|
|
|
|
// ═══════════════════ Order ═══════════════════
|
|
|
|
[Fact]
|
|
public async Task The_newest_file_is_on_top()
|
|
{
|
|
HourlyFiles(6);
|
|
|
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
|
|
|
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
|
result.Content.ShouldContain("newest first");
|
|
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 Sorted_by_name_runs_ascending()
|
|
{
|
|
HourlyFiles(4);
|
|
|
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"name"}""");
|
|
|
|
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 The_direction_can_be_reversed()
|
|
{
|
|
HourlyFiles(4);
|
|
|
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"modified","order":"asc"}""");
|
|
|
|
result.Content.ShouldContain("oldest first");
|
|
Names(result.Content).First().ShouldBe("berlin_2026-09-21_00-00.txt");
|
|
}
|
|
|
|
// ═══════════════════ Cap and total count ═══════════════════
|
|
|
|
[Fact]
|
|
public async Task The_header_always_names_the_total()
|
|
{
|
|
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.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");
|
|
Names(result.Content).Count.ShouldBe(FileRWTool.DefaultEntries);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_custom_limit_is_respected_and_capped()
|
|
{
|
|
HourlyFiles(10);
|
|
|
|
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 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 A_pattern_answers_the_question_for_a_specific_file()
|
|
{
|
|
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:");
|
|
Names(result.Content).ShouldAllBe(n => n.StartsWith("berlin_2026-09-21_1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_empty_directory_is_not_an_error_but_a_zero()
|
|
{
|
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
|
|
|
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
|
result.Content.ShouldContain("0 of 0 entries");
|
|
}
|
|
|
|
// ═══════════════════ Form ═══════════════════
|
|
|
|
[Fact]
|
|
public async Task Every_entry_is_on_exactly_one_line()
|
|
{
|
|
HourlyFiles(3);
|
|
Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden", "alt"));
|
|
|
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
|
|
|
|
result.Content.ShouldNotContain("\"Name\":", Case.Sensitive, "kein eingeruecktes JSON mehr");
|
|
result.Content.ShouldContain("alt/", Case.Sensitive);
|
|
result.Content.ShouldContain("<DIR>", Case.Sensitive);
|
|
Names(result.Content).Count.ShouldBe(4);
|
|
}
|
|
|
|
// ═══════════════════ The pattern is not a second path ═══════════════════
|
|
|
|
[Theory]
|
|
[InlineData("list")]
|
|
[InlineData("read_many")]
|
|
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.
|
|
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":"../*"}""");
|
|
|
|
result.Success.ShouldBeFalse("ein Muster ist ein Dateiname, kein Pfad");
|
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("pattern", Case.Insensitive);
|
|
(result.Content ?? "").ShouldNotContain("geheim");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("unter/*.txt")]
|
|
[InlineData("..\\*")]
|
|
[InlineData("*/../*")]
|
|
public async Task The_other_path_spellings_are_rejected_too(string pattern)
|
|
{
|
|
HourlyFiles(2);
|
|
|
|
var input = JsonSerializer.SerializeToElement(
|
|
new { action = "list", workspace = "shared", path = "wetter/stunden", pattern = pattern });
|
|
var result = await _tool.ExecuteAsync(input, Context(), CancellationToken.None);
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("not a path");
|
|
}
|
|
}
|