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; /// #714: Sub-tasks under the task they serve — through the tool, with its checks. public sealed class ParentTaskTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); private readonly SqliteTaskRepository _repo; private readonly TaskboardTool _tool = new(); private readonly OrgChart _org = new( [ new UnitConfig { Id = "marketing", Name = "Marketing" }, new UnitConfig { Id = "accounting", Name = "Accounting" } ], [ new AgentConfig { AgentId = "ads", UnitId = "marketing" }, new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" } ]); public ParentTaskTests() { _repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private Task Run(string agentId, object input) => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), new AgentToolContext( agentId, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo) { Organisation = _org }, CancellationToken.None); private async Task Create(string agentId, string title, string? parent = null) { var result = await Run(agentId, new { action = "task_create", title, parent, force = true }); result.Success.ShouldBeTrue(result.ErrorMessage); return result.Content.Split(' ', 4)[2]; } [Fact] public async Task A_sub_task_is_created_under_the_task_it_serves_and_listed_so() { var top = await Create("ads", "Spring collection"); var sub = await Create("ads", "Listings for the collection", top); (await _repo.GetAsync(sub, default))!.ParentId.ShouldBe(top); (await Run("ads", new { action = "task_list" })).Content.ShouldContain($"Serves: {top}"); } [Fact] public async Task A_parent_that_does_not_exist_is_refused() { var result = await Run("ads", new { action = "task_create", title = "Orphan", parent = "t-nowhere" }); result.Success.ShouldBeFalse(); result.ErrorMessage!.ShouldContain("No task found"); } [Fact] public async Task A_parent_on_another_board_is_answered_like_one_that_does_not_exist() { var foreign = await Create("bookkeeper", "Close the books"); var result = await Run("ads", new { action = "task_create", title = "Sneaky", parent = foreign }); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldBe($"No task found with ID '{foreign}' to serve as parent."); } [Fact] public async Task A_task_cannot_be_moved_below_its_own_sub_task() { var top = await Create("ads", "Spring collection"); var sub = await Create("ads", "Listings", top); var result = await Run("ads", new { action = "task_update", id = top, parent = sub }); result.Success.ShouldBeFalse(); result.ErrorMessage!.ShouldContain("cannot serve itself"); } [Fact] public async Task An_empty_parent_removes_it() { var top = await Create("ads", "Spring collection"); var sub = await Create("ads", "Listings", top); (await Run("ads", new { action = "task_update", id = sub, parent = "" })).Success.ShouldBeTrue(); (await _repo.GetAsync(sub, default))!.ParentId.ShouldBeEmpty(); } }