FileRW list sortiert und gedeckelt (#673), Taskboard task_find (#674)

Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend, die
es gab: 105 Eintraege in der Reihenfolge des Dateisystems, vierzeilig als
eingeruecktes JSON. list liefert jetzt die 50 zuletzt geaenderten Eintraege,
neueste zuerst, eine Zeile je Eintrag, mit Gesamtzahl im Kopf; pattern, sort,
order und limit steuern das.

Dabei aufgefallen (#675): Directory.GetFiles wertet Trennzeichen im Muster
aus - pattern "../*" fuehrte an der Pfadpruefung vorbei aus dem Verzeichnis
heraus, in list wie in read_many. Muster mit '/', '\' oder '..' werden jetzt
abgewiesen.

Und derselbe Befund ist keine zweite Aufgabe (#674): task_find beantwortet
die Frage vor dem Anlegen, task_create legt bei gleichbedeutendem Titel einer
offenen Aufgabe keine zweite an, sondern vermerkt das erneute Auftreten an
der bestehenden. force=true geht trotzdem, Erledigtes blockiert nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-21 21:19:19 +02:00
co-authored by Claude Opus 5
parent 24e457b11f
commit 060dc6a6ad
10 changed files with 815 additions and 19 deletions
@@ -0,0 +1,214 @@
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>
/// Sortiertes, gedeckeltes Auflisten (#673).
///
/// Anlass aus dem Testbetrieb: Die Betriebskontrolle meldete zwei Stundendateien als
/// fehlend, die es gab. Das Verzeichnis lieferte 105 Eintraege in der Reihenfolge des
/// Dateisystems — darin zwei Namen zu finden ist Sucharbeit, und ein guenstiges Modell
/// erledigt sie falsch.
/// </summary>
public sealed class VerzeichnisListeTests : IDisposable
{
private readonly string _basis;
private readonly string _personal;
private readonly string _shared;
private readonly FileRWTool _tool = new();
public VerzeichnisListeTests()
{
_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 { /* Aufräumen ist Nebensache */ }
}
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>Stundendateien, absichtlich in gemischter Reihenfolge geschrieben.</summary>
private void Stundendateien(int anzahl, string tag = "2026-09-21")
{
var verzeichnis = Path.Combine(_shared, "wetter", "stunden");
foreach (var stunde in Enumerable.Range(0, anzahl).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"));
}
}
private static List<string> Namen(string inhalt) => inhalt
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(z => z.Trim())
.Where(z => z.StartsWith("20", StringComparison.Ordinal))
.Select(z => z.Split(" ", StringSplitOptions.RemoveEmptyEntries).Last())
.ToList();
// ═══════════════════ Reihenfolge ═══════════════════
[Fact]
public async Task Die_juengste_Datei_steht_oben()
{
Stundendateien(6);
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("neueste zuerst");
Namen(result.Content).First().ShouldBe("berlin_2026-09-21_05-00.txt");
Namen(result.Content).Last().ShouldBe("berlin_2026-09-21_00-00.txt");
}
[Fact]
public async Task Nach_Namen_sortiert_laeuft_aufsteigend()
{
Stundendateien(4);
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"name"}""");
Namen(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()
{
Stundendateien(4);
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"modified","order":"asc"}""");
result.Content.ShouldContain("aelteste zuerst");
Namen(result.Content).First().ShouldBe("berlin_2026-09-21_00-00.txt");
}
// ═══════════════════ Deckel und Gesamtzahl ═══════════════════
[Fact]
public async Task Der_Kopf_nennt_immer_die_Gesamtzahl()
{
Stundendateien(24);
Stundendateien(24, "2026-09-20");
Stundendateien(24, "2026-09-19");
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
result.Content.ShouldContain($"{FileRWTool.StandardEintraege} von 72 Eintraegen");
result.Content.ShouldContain("22 weitere nicht gezeigt");
result.Content.ShouldContain("'pattern'", Case.Sensitive, "der Agent soll wissen, wie er gezielt fragt");
Namen(result.Content).Count.ShouldBe(FileRWTool.StandardEintraege);
}
[Fact]
public async Task Ein_eigenes_Limit_wird_beachtet_und_gedeckelt()
{
Stundendateien(10);
var wenig = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":3}""");
Namen(wenig.Content).Count.ShouldBe(3);
wenig.Content.ShouldContain("3 von 10 Eintraegen");
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");
}
// ═══════════════════ Gezielt fragen ═══════════════════
[Fact]
public async Task Ein_Muster_beantwortet_die_Frage_nach_einer_bestimmten_Datei()
{
Stundendateien(24);
Stundendateien(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 von 10 Eintraegen");
result.Content.ShouldContain("Muster:");
Namen(result.Content).ShouldAllBe(n => n.StartsWith("berlin_2026-09-21_1"));
}
[Fact]
public async Task Ein_leeres_Verzeichnis_ist_kein_Fehler_sondern_eine_Null()
{
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("0 von 0 Eintraegen");
}
// ═══════════════════ Form ═══════════════════
[Fact]
public async Task Jeder_Eintrag_steht_auf_genau_einer_Zeile()
{
Stundendateien(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);
Namen(result.Content).Count.ShouldBe(4);
}
// ═══════════════════ Das Muster ist kein zweiter Pfad ═══════════════════
[Theory]
[InlineData("list")]
[InlineData("read_many")]
public async Task Ein_Muster_darf_nicht_aus_dem_Verzeichnis_herausfuehren(string action)
{
// Directory.GetFiles wertet Trennzeichen im Muster aus: "../*" listet das
// Elternverzeichnis, ohne dass die Pfadpruefung je gefragt wurde.
Stundendateien(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("Muster");
(result.Content ?? "").ShouldNotContain("geheim");
}
[Theory]
[InlineData("unter/*.txt")]
[InlineData("..\\*")]
[InlineData("*/../*")]
public async Task Auch_die_anderen_Pfadschreibweisen_werden_abgewiesen(string muster)
{
Stundendateien(2);
var input = JsonSerializer.SerializeToElement(
new { action = "list", workspace = "shared", path = "wetter/stunden", pattern = muster });
var result = await _tool.ExecuteAsync(input, Context(), CancellationToken.None);
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("kein Pfad");
}
}