Files
ClawdDotNet/tests/ClawdDotNet.Tools.Tests/Taskboard/UnitBoardTests.cs
T
RichardandClaude Opus 5.5 29d953a5eb Brett je Einheit, Stufe 3: Aufgaben sehen, wer sie sehen soll (#684)
- TaskBoard: das Brett einer Aufgabe wird nicht gespeichert, es folgt aus
  der Organisation - Einheit des Zustaendigen, sonst des Anlegers. Offene
  Aufgaben ziehen damit beim Umzug mit. Sichtbar: das eigene Brett, fuer eine
  Leitung alle darunter, und was man selbst angelegt hat (nach oben
  Gereichtes bleibt im Blick).
- TaskItem.CreatedBy, in der Datei als created_by und in der DB. Ein Import
  aus einer Datei ohne das Feld behaelt, was die DB weiss.
- Taskboard-Tool: task_list, task_find und die Dublettenpruefung sehen nur
  die eigenen Bretter - eine aehnliche Buchhaltungsaufgabe haelt keine
  Marketingaufgabe auf und bekommt keinen Kommentar von dort. task_update und
  task_comment antworten auf eine fremde Aufgabe wie auf eine unbekannte.
- TaskboardService.ListAsync mit Sichtfilter: erst filtern, dann kappen.
- WebUI: jede Aufgabe nennt ihr Brett, ein Filter waehlt eines aus.
- Konzept (Abschnitt 3) und Einrichtungshandbuch nachgetragen.

Ohne Einheiten aendert sich nichts - ein Brett fuer alle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:03:39 +02:00

187 lines
7.5 KiB
C#

using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Taskboard;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Taskboard;
/// <summary>
/// #684: One board per unit. The bookkeeper's tasks do not show up for marketing, do not
/// hold up a marketing task as a "duplicate", and cannot be commented on from outside.
///
/// <code>
/// marketing (lead: marketing-lead)
/// └── amazon (lead: amazon-lead) · ads
/// accounting (lead: accounting-lead) · bookkeeper
/// ceo on the top level
/// </code>
/// </summary>
public sealed class UnitBoardTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly TaskboardTool _tool = new();
private readonly List<AgentConfig> _agents =
[
new() { AgentId = "ceo" },
new() { AgentId = "marketing-lead", UnitId = "marketing" },
new() { AgentId = "amazon-lead", UnitId = "amazon" },
new() { AgentId = "ads", UnitId = "amazon" },
new() { AgentId = "accounting-lead", UnitId = "accounting" },
new() { AgentId = "bookkeeper", UnitId = "accounting" }
];
private readonly List<UnitConfig> _units =
[
new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
new() { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" }
];
public UnitBoardTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private string TasksDir => Path.Combine(_directory, "SharedWorkspace", "tasks");
private async Task<ToolResult> Run(string agentId, object input, bool withUnits = true)
{
var context = new AgentToolContext(
agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo)
{
Organisation = withUnits ? new OrgChart(_units, _agents) : new OrgChart([], _agents)
};
return await _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None);
}
private async Task<string> Create(string agentId, string title, string assignee = "@human")
{
var result = await Run(agentId, new { action = "task_create", title, assignee, force = true });
result.Success.ShouldBeTrue(result.ErrorMessage);
return result.Content.Split(' ', 4)[2]; // "Task created: <id> — …"
}
private async Task<string> List(string agentId, bool withUnits = true)
=> (await Run(agentId, new { action = "task_list" }, withUnits)).Content;
[Fact]
public async Task Each_unit_sees_its_own_board()
{
await Create("bookkeeper", "Book the invoices of September");
await Create("ads", "Raise the bid for chairs");
var marketing = await List("ads");
var accounting = await List("bookkeeper");
marketing.ShouldContain("Raise the bid");
marketing.ShouldNotContain("invoices");
accounting.ShouldContain("invoices");
accounting.ShouldNotContain("Raise the bid");
}
[Fact]
public async Task A_lead_also_sees_the_boards_of_the_units_below()
{
await Create("ads", "Raise the bid for chairs");
await Create("bookkeeper", "Book the invoices of September");
var lead = await List("marketing-lead");
lead.ShouldContain("Raise the bid", Case.Sensitive, "amazon lies below marketing");
lead.ShouldNotContain("invoices");
}
[Fact]
public async Task A_task_handed_up_stays_in_view_of_whoever_handed_it_up()
{
// amazon-lead's lead is marketing-lead — the task lands on marketing's board …
await Create("amazon-lead", "Approve the new budget", "@marketing-lead");
(await List("marketing-lead")).ShouldContain("Approve the new budget");
// … and the one who created it still sees it, although amazon's board does not hold it.
(await List("amazon-lead")).ShouldContain("Approve the new budget");
(await List("ads")).ShouldNotContain("Approve the new budget");
}
[Fact]
public async Task A_similar_task_on_another_board_is_no_duplicate()
{
await Create("bookkeeper", "Check the supplier invoice");
var result = await Run("ads", new { action = "task_create", title = "Check the supplier invoice" });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldNotContain("Observed again");
(await Run("bookkeeper", new { action = "task_find", title = "Check the supplier invoice" })).Content
.ShouldContain("1 open task(s)", Case.Sensitive, "and nothing was written onto the bookkeeper's task");
}
[Fact]
public async Task A_task_on_another_board_cannot_be_changed_or_commented_on()
{
var id = await Create("bookkeeper", "Book the invoices of September");
var update = await Run("ads", new { action = "task_update", id, status = "canceled" });
var comment = await Run("ads", new { action = "task_comment", id, text = "not needed" });
var unknown = await Run("ads", new { action = "task_comment", id = "t-does-not-exist", text = "x" });
update.Success.ShouldBeFalse();
comment.Success.ShouldBeFalse();
comment.ErrorMessage!.Replace(id, "X").ShouldBe(unknown.ErrorMessage!.Replace("t-does-not-exist", "X"),
"the same answer as for a task that does not exist");
(await _repo.GetAsync(id, default))!.Status.ShouldBe(TaskItemStatus.Todo);
}
[Fact]
public async Task An_open_task_moves_with_its_agent()
{
await Create("amazon-lead", "Prepare the listing", "@ads");
(await List("bookkeeper")).ShouldNotContain("Prepare the listing");
_agents.Single(a => a.AgentId == "ads").UnitId = "accounting";
(await List("bookkeeper")).ShouldContain("Prepare the listing", Case.Sensitive, "the board follows the assignee");
}
[Fact]
public async Task Without_units_there_is_one_board_for_everyone()
{
await Create("bookkeeper", "Book the invoices of September");
(await List("ads", withUnits: false)).ShouldContain("invoices");
}
[Fact]
public async Task The_creator_is_kept_in_the_file_and_survives_a_re_import()
{
var id = await Create("ads", "Raise the bid for chairs");
var file = Directory.GetFiles(TasksDir).Single();
File.ReadAllText(file).ShouldContain("created_by: ads");
// A file written by hand without the field keeps what the database knows.
File.WriteAllText(file, File.ReadAllText(file).Replace("created_by: ads\n", "").Replace("created_by: ads\r\n", ""));
await new TaskboardService(_repo, TasksDir).ImportAllAsync(default);
(await _repo.GetAsync(id, default))!.CreatedBy.ShouldBe("ads");
}
}