222 lines
7.8 KiB
C#
222 lines
7.8 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Services;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Xunit;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Hub;
|
|
|
|
public sealed class PlanningHubTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly string _rootDir;
|
|
private readonly PlanningSessionManager _planning;
|
|
private readonly FakePlanningLauncher _launcher;
|
|
private readonly RecordingClientProxy _proxy;
|
|
|
|
public PlanningHubTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}");
|
|
_planning = new PlanningSessionManager(_tasks, _lists, _rootDir);
|
|
_launcher = new FakePlanningLauncher();
|
|
_proxy = new RecordingClientProxy();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
try { Directory.Delete(_rootDir, recursive: true); } catch { }
|
|
}
|
|
|
|
private WorkerHub CreateHub()
|
|
{
|
|
var hub = new WorkerHub(
|
|
null!, null!, null!, null!, null!, null!, null!, null!,
|
|
_planning, _launcher);
|
|
hub.Clients = new FakeHubCallerClients(_proxy);
|
|
hub.Context = new FakeHubCallerContext();
|
|
return hub;
|
|
}
|
|
|
|
private async Task<(string listId, string taskId)> SeedAsync()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(wd);
|
|
await _lists.AddAsync(new ListEntity
|
|
{
|
|
Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "Do something",
|
|
Status = TaskStatus.Manual,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "feat",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return (listId, task.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
var hub = CreateHub();
|
|
|
|
var ctx = await hub.StartPlanningSessionAsync(taskId);
|
|
|
|
Assert.Equal(taskId, ctx.ParentTaskId);
|
|
Assert.Equal(1, _launcher.LaunchStartCalls);
|
|
Assert.Equal(0, _launcher.LaunchResumeCalls);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
|
|
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartPlanningSessionAsync_LauncherFails_Discards()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
_launcher.ShouldThrow = true;
|
|
var hub = CreateHub();
|
|
|
|
await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
|
hub.StartPlanningSessionAsync(taskId));
|
|
|
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
|
|
|
var sessionDir = Path.Combine(_rootDir, taskId);
|
|
Assert.False(Directory.Exists(sessionDir));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
// Put task into Planning state first
|
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
|
_proxy.Sent.Clear();
|
|
|
|
var hub = CreateHub();
|
|
await hub.DiscardPlanningSessionAsync(taskId);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
|
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
|
|
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
|
|
_proxy.Sent.Clear();
|
|
|
|
var hub = CreateHub();
|
|
var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false);
|
|
|
|
Assert.Equal(2, count);
|
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPendingDraftCountAsync_ReturnsCount()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
|
await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
|
|
await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
|
|
|
|
var hub = CreateHub();
|
|
var count = await hub.GetPendingDraftCountAsync(taskId);
|
|
|
|
Assert.Equal(2, count);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
|
|
{
|
|
public bool ShouldThrow { get; set; }
|
|
public int LaunchStartCalls { get; private set; }
|
|
public int LaunchResumeCalls { get; private set; }
|
|
|
|
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
|
{
|
|
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
|
LaunchStartCalls++;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
|
{
|
|
LaunchResumeCalls++;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
internal sealed class RecordingClientProxy : IClientProxy
|
|
{
|
|
public List<(string method, object?[] args)> Sent { get; } = new();
|
|
|
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
|
{
|
|
Sent.Add((method, args));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
internal sealed class FakeHubCallerClients : IHubCallerClients
|
|
{
|
|
private readonly IClientProxy _all;
|
|
public FakeHubCallerClients(IClientProxy proxy) => _all = proxy;
|
|
|
|
public IClientProxy All => _all;
|
|
public IClientProxy Caller => _all;
|
|
public IClientProxy Others => _all;
|
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => _all;
|
|
public IClientProxy Client(string connectionId) => _all;
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => _all;
|
|
public IClientProxy Group(string groupName) => _all;
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => _all;
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => _all;
|
|
public IClientProxy OthersInGroup(string groupName) => _all;
|
|
public IClientProxy User(string userId) => _all;
|
|
public IClientProxy Users(IReadOnlyList<string> userIds) => _all;
|
|
}
|
|
|
|
internal sealed class FakeHubCallerContext : HubCallerContext
|
|
{
|
|
public override string ConnectionId => "test-conn";
|
|
public override string? UserIdentifier => null;
|
|
public override System.Security.Claims.ClaimsPrincipal? User => null;
|
|
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
|
public override Microsoft.AspNetCore.Http.Features.IFeatureCollection Features { get; } =
|
|
new Microsoft.AspNetCore.Http.Features.FeatureCollection();
|
|
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
|
public override void Abort() { }
|
|
}
|