Merge branch 'claudedo/5c80a0aba5d9468a96cf809fc7a6e7a1'
This commit is contained in:
+248
-4
@@ -185,7 +185,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None);
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("new title", dto.Title);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
@@ -206,7 +206,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None);
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(task.Id, dto.Id);
|
||||
Assert.Equal(listId, dto.ListId);
|
||||
@@ -289,6 +289,91 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Equal("error", dto.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_QueuedWithUnmetDependsOn_ReportsBlockedTrueAndReason()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
||||
task.DependsOnTaskId = predecessor.Id;
|
||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.True(dto.Blocked);
|
||||
Assert.Contains(predecessor.Id, dto.BlockedReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_QueuedWithDoneDependsOn_ReportsBlockedFalse()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done);
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
||||
task.DependsOnTaskId = predecessor.Id;
|
||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(dto.Blocked);
|
||||
Assert.Null(dto.BlockedReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued()
|
||||
{
|
||||
// Blocking is only meaningful once the picker would actually be gating the task --
|
||||
// an Idle task hasn't been queued at all, so DependsOnTaskId doesn't apply yet.
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
task.DependsOnTaskId = predecessor.Id;
|
||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(dto.Blocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasks_ReportsBlockedPerTask()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
||||
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
||||
blocked.DependsOnTaskId = predecessor.Id;
|
||||
await _tasks.UpdateAsync(blocked, CancellationToken.None);
|
||||
var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None);
|
||||
|
||||
var blockedRef = result.Tasks!.Single(t => t.Id == blocked.Id);
|
||||
var unblockedRef = result.Tasks!.Single(t => t.Id == unblocked.Id);
|
||||
Assert.True(blockedRef.Blocked);
|
||||
Assert.Contains(predecessor.Id, blockedRef.BlockedReason);
|
||||
Assert.False(unblockedRef.Blocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasks_IncludeDescriptionTrue_ReportsBlockedOnFullDto()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
||||
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
||||
blocked.DependsOnTaskId = predecessor.Id;
|
||||
await _tasks.UpdateAsync(blocked, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
var dto = result.TasksFull!.Single(t => t.Id == blocked.Id);
|
||||
Assert.True(dto.Blocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_OnRunning_Throws()
|
||||
{
|
||||
@@ -298,7 +383,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None));
|
||||
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -308,7 +393,51 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None));
|
||||
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_WithDependsOnTaskId_SetsLinkAndReportsBlocked()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(predecessor.Id, dto.DependsOnTaskId);
|
||||
Assert.True(dto.Blocked);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_WithEmptyStringDependsOnTaskId_ClearsExistingLink()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None);
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Null(dto.DependsOnTaskId);
|
||||
Assert.False(dto.Blocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_WithCyclicDependsOnTaskId_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var a = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var b = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1911,6 +2040,92 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||
await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n");
|
||||
|
||||
// The target branch itself moved on and touched the same file after the fork point.
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Behind);
|
||||
Assert.NotNull(result.StaleFiles);
|
||||
Assert.Contains("shared.txt", result.StaleFiles!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||
await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n");
|
||||
|
||||
// Target moved on (behind > 0) but touched an entirely different file -- a stale
|
||||
// branch that still collides with nothing.
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Behind);
|
||||
Assert.NotNull(result.StaleFiles);
|
||||
Assert.Empty(result.StaleFiles!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
||||
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var t = await ctx.Tasks.FindAsync(task.Id);
|
||||
t!.HandlerBaseCommit = repo.BaseCommit;
|
||||
t.HandlerHeadCommit = headCommit;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result.StaleFiles);
|
||||
Assert.Empty(result.StaleFiles!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap()
|
||||
{
|
||||
@@ -2152,6 +2367,35 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
() => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_WithDependsOnTaskId_PersistsLinkAndReportsBlocked()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||
var sut = NewService();
|
||||
|
||||
var dto = await sut.AddTask(
|
||||
listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(predecessor.Id, dto.Task.DependsOnTaskId);
|
||||
Assert.True(dto.Task.Blocked);
|
||||
Assert.Contains(predecessor.Id, dto.Task.BlockedReason);
|
||||
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
||||
Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_WithSelfReferencingDependsOnTaskId_Throws()
|
||||
{
|
||||
// Can't reference its own not-yet-known id, so this exercises the not-found path --
|
||||
// an unknown dependsOnTaskId is rejected the same way a self-reference would be.
|
||||
var listId = await SeedListAsync();
|
||||
var sut = NewService();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.AddTask(listId, "t", dependsOnTaskId: "does-not-exist", cancellationToken: CancellationToken.None));
|
||||
}
|
||||
|
||||
// ── AddTask possible-duplicate check ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user