Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs
T
mika kuns 38af549a80 feat(ui,worker): surface task numbers in row/detail UI and worker log
Slice 4/5 of task-numbers: TaskRowViewModel.Number renders as a dimmed
"#123" before the row title; DetailsIslandViewModel.TaskIdBadge now
shows "#123" instead of the unusable "#T<guid-prefix>" handle; and the
curated WorkerLog business events in TaskRunner, TaskMergeService, and
TaskResetService prefix their quoted title with "#<Number>".
2026-08-11 13:51:20 +02:00

283 lines
9.7 KiB
C#

using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
public class TaskResetServiceTests : IDisposable
{
private readonly List<DbFixture> _dbs = new();
private readonly List<GitRepoFixture> _repos = new();
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose()
{
foreach (var (repoDir, wtPath) in _worktreeCleanups)
{
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
}
foreach (var d in _dbs) try { d.Dispose(); } catch { }
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
private static (TaskResetService svc, RecordingClientProxy proxy) BuildService(DbFixture db, WorktreeManager wtMgr)
{
var fakeHub = new RecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var built = TaskStateServiceBuilder.Build(db.CreateFactory());
var svc = new TaskResetService(
db.CreateFactory(),
wtMgr,
broadcaster,
built.State,
NullLogger<TaskResetService>.Instance);
return (svc, fakeHub.Proxy);
}
private static WorktreeManager BuildWorktreeManager(DbFixture db)
{
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
return new WorktreeManager(
new ClaudeDo.Data.Git.GitService(),
db.CreateFactory(),
cfg,
NullLogger<WorktreeManager>.Instance);
}
[Fact]
public async Task ResetAsync_FailedTaskWithWorktree_ClearsEverything_AndPreservesRunHistory()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var wtMgr = BuildWorktreeManager(db);
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "reset-test",
WorkingDir = repo.RepoDir,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "test task",
Status = TaskStatus.Failed,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow.AddMinutes(-1),
Result = "some error",
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
await new ListRepository(ctx).AddAsync(list);
await new TaskRepository(ctx).AddAsync(task);
}
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
using (var ctx = db.CreateContext())
{
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = task.Id,
RunNumber = 1,
IsRetry = false,
Prompt = "do the thing",
SessionId = "s1",
});
}
var (svc, proxy) = BuildService(db, wtMgr);
await svc.ResetAsync(task.Id, CancellationToken.None);
// Task must be Manual with cleared timestamps/result
using (var ctx = db.CreateContext())
{
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.NotNull(updated);
Assert.Equal(TaskStatus.Idle, updated!.Status);
Assert.Null(updated.Result);
Assert.Null(updated.StartedAt);
Assert.Null(updated.FinishedAt);
}
// Worktree directory must be gone
Assert.False(Directory.Exists(wtCtx.WorktreePath));
// Worktree DB row must be Discarded
using (var ctx = db.CreateContext())
{
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.NotNull(wt);
Assert.Equal(WorktreeState.Discarded, wt!.State);
}
// task_runs row must still be present
using (var ctx = db.CreateContext())
{
var runs = await new TaskRunRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Single(runs);
Assert.Equal("s1", runs[0].SessionId);
}
// Broadcaster must have fired TaskUpdated AND WorktreeUpdated
Assert.Contains(proxy.Calls, i => i.Method == "TaskUpdated" && i.Args[0] is string s && s == task.Id);
Assert.Contains(proxy.Calls, i => i.Method == "WorktreeUpdated" && i.Args[0] is string s && s == task.Id);
}
[Fact]
public async Task ResetAsync_BroadcastsWorkerLog_ContainingTaskNumber()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var wtMgr = BuildWorktreeManager(db);
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "reset-number-test",
WorkingDir = repo.RepoDir,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "numbered task",
Number = 321,
Status = TaskStatus.Failed,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow.AddMinutes(-1),
Result = "some error",
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
// Bypass TaskRepository.AddAsync — it allocates its own Number via
// TaskNumberAllocator and would silently overwrite the one this test sets.
ctx.Lists.Add(list);
ctx.Tasks.Add(task);
await ctx.SaveChangesAsync();
}
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
var (svc, proxy) = BuildService(db, wtMgr);
await svc.ResetAsync(task.Id, CancellationToken.None);
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("#321"));
}
[Fact]
public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate()
{
var db = NewDb();
var wtMgr = BuildWorktreeManager(db);
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "no-op list",
WorkingDir = null,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var startedAt = DateTime.UtcNow.AddMinutes(-2);
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "running task",
Status = TaskStatus.Running,
StartedAt = startedAt,
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
await new ListRepository(ctx).AddAsync(list);
await new TaskRepository(ctx).AddAsync(task);
}
var (svc, proxy) = BuildService(db, wtMgr);
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.ResetAsync(task.Id, CancellationToken.None));
// Task must be unchanged
using (var ctx = db.CreateContext())
{
var unchanged = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.NotNull(unchanged);
Assert.Equal(TaskStatus.Running, unchanged!.Status);
Assert.Equal(startedAt, unchanged.StartedAt);
}
// No broadcaster invocations
Assert.Empty(proxy.Calls);
}
}
#region Test doubles
internal sealed record HubCall(string Method, object?[] Args);
internal sealed class RecordingClientProxy : IClientProxy
{
public readonly List<HubCall> Calls = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Calls.Add(new HubCall(method, args));
return Task.CompletedTask;
}
}
internal sealed class RecordingHubClients : IHubClients
{
public RecordingClientProxy AllProxy { get; } = new();
public IClientProxy All => AllProxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
public IClientProxy Client(string connectionId) => AllProxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
public IClientProxy Group(string groupName) => AllProxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
public IClientProxy User(string userId) => AllProxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}
internal sealed class RecordingHubContext : IHubContext<ClaudeDo.Worker.Hub.WorkerHub>
{
private readonly RecordingHubClients _clients = new();
public RecordingClientProxy Proxy => _clients.AllProxy;
public IHubClients Clients => _clients;
public IGroupManager Groups => throw new NotImplementedException();
}
#endregion