Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Hub/HandlerOpenTasksHubTests.cs
T
mika kuns bd5aa265d1 feat(mission-control): Handler-Run per Knopf abbrechen
Ein "Abandon run"-Knopf an Handler-Panes fragt HandlerRoundState.Get(handlerTaskId)
nach den noch offenen (Queued/Running) Tasks des Laufs, bestaetigt mit Anzahl,
cancelt jeden ueber den bestehenden CancelTaskAsync-Pfad und schliesst danach die
Session -- ein Schritt statt Tile schliessen + Queue separat abbrechen. Ohne
offene Tasks (oder ohne Round-Eintrag) schliesst die Aktion direkt, ohne Dialog.
Nur an den beiden Handler-Panes sichtbar (IsListHandler), normale Task-/Ad-hoc-
Panes bleiben unveraendert.
2026-09-02 22:39:12 +02:00

142 lines
6.1 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Hub;
/// Covers WorkerHub.GetHandlerOpenTasks -- the Mission Control "abandon run" action's read side.
/// The round's TaskIds come from HandlerRoundState (structured store, not the handler task's
/// description); only rows still Queued or Running are returned, so WaitingForReview/Done/Failed/
/// Cancelled/Idle work is never touched by a subsequent cancel loop.
public sealed class HandlerOpenTasksHubTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly string _roundsPath = Path.Combine(Path.GetTempPath(), $"claudedo_rounds_{Guid.NewGuid():N}.json");
private readonly HandlerRoundState _rounds;
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
{
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Array.Empty<SessionSkillEntity>());
}
public HandlerOpenTasksHubTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_rounds = new HandlerRoundState(_roundsPath);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
try { File.Delete(_roundsPath); } catch { /* best effort */ }
}
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var git = new GitService();
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), Microsoft.Extensions.Logging.Abstractions.NullLogger<WorktreeManager>.Instance);
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker(), _rounds);
var built = TaskStateServiceBuilder.Build(factory);
var hub = new WorkerHub(
null!, null!, null!, null!, null!, factory, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git,
roundState: _rounds);
return hub;
}
private async Task<string> SeedListAsync(string name = "L")
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = Path.GetTempPath(), CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status, string title = "T")
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
CreatedAt = DateTime.UtcNow,
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task NoRoundEntry_ReturnsEmpty()
{
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks("no-such-handler-task");
Assert.Empty(result);
}
[Fact]
public async Task RoundWithOnlyTerminalTasks_ReturnsEmpty()
{
var listId = await SeedListAsync();
var done = await SeedTaskAsync(listId, TaskStatus.Done, "Done task");
var review = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, "Review task");
var failed = await SeedTaskAsync(listId, TaskStatus.Failed, "Failed task");
var cancelled = await SeedTaskAsync(listId, TaskStatus.Cancelled, "Cancelled task");
var idle = await SeedTaskAsync(listId, TaskStatus.Idle, "Idle task");
var handlerTaskId = "handler-1";
_rounds.Save(new HandlerRound(handlerTaskId, "wait",
new[] { done.Id, review.Id, failed.Id, cancelled.Id, idle.Id }, DateTime.UtcNow));
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks(handlerTaskId);
Assert.Empty(result);
}
[Fact]
public async Task RoundWithMixedTasks_ReturnsOnlyQueuedAndRunning()
{
var listId = await SeedListAsync();
var queued = await SeedTaskAsync(listId, TaskStatus.Queued, "Queued task");
var running = await SeedTaskAsync(listId, TaskStatus.Running, "Running task");
var done = await SeedTaskAsync(listId, TaskStatus.Done, "Done task");
var notInRound = await SeedTaskAsync(listId, TaskStatus.Queued, "Unrelated queued task");
var handlerTaskId = "handler-2";
_rounds.Save(new HandlerRound(handlerTaskId, "wait",
new[] { queued.Id, running.Id, done.Id }, DateTime.UtcNow));
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks(handlerTaskId);
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.Id == queued.Id && r.Title == "Queued task");
Assert.Contains(result, r => r.Id == running.Id && r.Title == "Running task");
Assert.DoesNotContain(result, r => r.Id == notInRound.Id);
}
}