Skip to content

Commit 1d75ae9

Browse files
committed
Refine projected WIP conflict detection
1 parent 62f202c commit 1d75ae9

2 files changed

Lines changed: 128 additions & 16 deletions

File tree

backend/src/Taskdeck.Application/Services/ProposalConflictDetector.cs

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,13 @@ public async Task<Result<IReadOnlyList<ConflictRowDto>>> DetectConflictsAsync(
4545
// Entity caches to avoid redundant DB lookups across sub-methods
4646
var cardCache = new Dictionary<Guid, Card?>();
4747
var columnCache = new Dictionary<Guid, Column?>();
48-
var projectedColumnAdditions = await GetProjectedColumnAdditionCountsAsync(
48+
var projectedColumnChanges = await GetProjectedColumnChangesAsync(
4949
proposal, cardCache, cancellationToken);
5050

5151
// Check each condition and collect rows
5252
await CheckStaleDataAsync(proposal, rows, flaggedCardIds, cardCache, cancellationToken);
5353
await CheckWipLimitAsync(proposal, rows, flaggedColumnIds, columnCache,
54-
projectedColumnAdditions, cancellationToken);
54+
projectedColumnChanges, cancellationToken);
5555
await CheckDuplicatePendingProposalsAsync(proposal, rows, cancellationToken);
5656
CheckHighRiskOperations(proposal, rows);
5757
await CheckOutboundWebhooksAsync(proposal, rows, cancellationToken);
@@ -67,7 +67,7 @@ await CheckWipLimitAsync(proposal, rows, flaggedColumnIds, columnCache,
6767
{
6868
// Add positive signals when applicable
6969
await AddPositiveSignalsAsync(proposal, rows, flaggedCardIds, flaggedColumnIds,
70-
cardCache, columnCache, projectedColumnAdditions, cancellationToken);
70+
cardCache, columnCache, projectedColumnChanges, cancellationToken);
7171
}
7272

7373
// Sort: Warn first, then Info, then Ok
@@ -146,17 +146,32 @@ private async Task CheckWipLimitAsync(
146146
List<ConflictRow> rows,
147147
HashSet<Guid> flaggedColumnIds,
148148
Dictionary<Guid, Column?> columnCache,
149-
IReadOnlyDictionary<Guid, int> projectedColumnAdditions,
149+
IReadOnlyDictionary<Guid, ColumnProjection> projectedColumnChanges,
150150
CancellationToken cancellationToken)
151151
{
152-
if (projectedColumnAdditions.Count == 0) return;
152+
if (projectedColumnChanges.Count == 0) return;
153153

154-
foreach (var (columnId, additionCount) in projectedColumnAdditions)
154+
foreach (var (columnId, projection) in projectedColumnChanges)
155155
{
156156
var column = await GetOrFetchColumnAsync(columnId, columnCache, cancellationToken);
157-
if (column is null) continue;
157+
if (column is null)
158+
{
159+
if (projection.ReceivesCards)
160+
{
161+
flaggedColumnIds.Add(columnId);
162+
rows.Add(new ConflictRow(
163+
ConflictTone.Warn,
164+
"missing-target-column",
165+
$"Target column {columnId:N} no longer exists"));
166+
}
167+
168+
continue;
169+
}
170+
171+
if (!projection.ReceivesCards)
172+
continue;
158173

159-
var projectedCount = column.Cards.Count + additionCount;
174+
var projectedCount = column.Cards.Count + projection.Delta;
160175
if (column.WipLimit.HasValue && projectedCount > column.WipLimit.Value)
161176
{
162177
flaggedColumnIds.Add(columnId);
@@ -299,18 +314,19 @@ private async Task AddPositiveSignalsAsync(
299314
HashSet<Guid> flaggedColumnIds,
300315
Dictionary<Guid, Card?> cardCache,
301316
Dictionary<Guid, Column?> columnCache,
302-
IReadOnlyDictionary<Guid, int> projectedColumnAdditions,
317+
IReadOnlyDictionary<Guid, ColumnProjection> projectedColumnChanges,
303318
CancellationToken cancellationToken)
304319
{
305320
// Ok: target column has capacity (only if we didn't already warn about WIP for this column)
306-
foreach (var (columnId, additionCount) in projectedColumnAdditions)
321+
foreach (var (columnId, projection) in projectedColumnChanges)
307322
{
323+
if (!projection.ReceivesCards) continue;
308324
if (flaggedColumnIds.Contains(columnId)) continue;
309325

310326
var column = await GetOrFetchColumnAsync(columnId, columnCache, cancellationToken);
311327
if (column is null) continue;
312328

313-
var projectedCount = column.Cards.Count + additionCount;
329+
var projectedCount = column.Cards.Count + projection.Delta;
314330
if (column.WipLimit.HasValue && projectedCount <= column.WipLimit.Value)
315331
{
316332
rows.Add(new ConflictRow(
@@ -355,15 +371,15 @@ private static List<Guid> GetDistinctCardTargetIds(AutomationProposal proposal,
355371
}
356372

357373
/// <summary>
358-
/// Counts cards each proposal operation would add to a target column.
374+
/// Projects card count deltas per column for create/move operations.
359375
/// Existing cards moved within their current column do not increase projected WIP.
360376
/// </summary>
361-
private async Task<IReadOnlyDictionary<Guid, int>> GetProjectedColumnAdditionCountsAsync(
377+
private async Task<IReadOnlyDictionary<Guid, ColumnProjection>> GetProjectedColumnChangesAsync(
362378
AutomationProposal proposal,
363379
Dictionary<Guid, Card?> cardCache,
364380
CancellationToken cancellationToken)
365381
{
366-
var additions = new Dictionary<Guid, int>();
382+
var changes = new Dictionary<Guid, ColumnProjection>();
367383

368384
foreach (var op in proposal.Operations)
369385
{
@@ -374,19 +390,37 @@ private async Task<IReadOnlyDictionary<Guid, int>> GetProjectedColumnAdditionCou
374390
if (!targetColumnId.HasValue)
375391
continue;
376392

393+
var sourceColumnId = (Guid?)null;
377394
if (op.ActionType.Equals("move", StringComparison.OrdinalIgnoreCase)
378395
&& op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase)
379396
&& Guid.TryParse(op.TargetId, out var movedCardId))
380397
{
381398
var card = await GetOrFetchCardAsync(movedCardId, cardCache, cancellationToken);
382399
if (card?.ColumnId == targetColumnId.Value)
383400
continue;
401+
402+
sourceColumnId = card?.ColumnId;
384403
}
385404

386-
additions[targetColumnId.Value] = additions.GetValueOrDefault(targetColumnId.Value) + 1;
405+
if (sourceColumnId.HasValue)
406+
AddColumnProjectionDelta(changes, sourceColumnId.Value, delta: -1, receivesCards: false);
407+
408+
AddColumnProjectionDelta(changes, targetColumnId.Value, delta: 1, receivesCards: true);
387409
}
388410

389-
return additions;
411+
return changes;
412+
}
413+
414+
private static void AddColumnProjectionDelta(
415+
Dictionary<Guid, ColumnProjection> changes,
416+
Guid columnId,
417+
int delta,
418+
bool receivesCards)
419+
{
420+
var existing = changes.GetValueOrDefault(columnId);
421+
changes[columnId] = new ColumnProjection(
422+
existing.Delta + delta,
423+
existing.ReceivesCards || receivesCards);
390424
}
391425

392426
/// <summary>
@@ -442,6 +476,8 @@ private static bool AddsCardToColumn(AutomationProposalOperation operation)
442476
|| operation.ActionType.Equals("move", StringComparison.OrdinalIgnoreCase);
443477
}
444478

479+
private readonly record struct ColumnProjection(int Delta, bool ReceivesCards);
480+
445481
private static IReadOnlyList<string> GetWebhookEventTypes(AutomationProposal proposal)
446482
{
447483
return proposal.Operations

backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,82 @@ public async Task DetectConflictsAsync_MoveWithinSameColumn_DoesNotIncreaseProje
313313
result.Value.Should().NotContain(r => r.Key == "wip-limit");
314314
}
315315

316+
[Fact]
317+
public async Task DetectConflictsAsync_CreateIntoMissingColumn_ReturnsMissingTargetColumnWarning()
318+
{
319+
var columnId = Guid.NewGuid();
320+
var proposal = CreateProposal(_userId, _boardId);
321+
proposal.AddOperation(new AutomationProposalOperation(
322+
proposal.Id, 0, "create", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString()));
323+
324+
_proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny<CancellationToken>()))
325+
.ReturnsAsync(proposal);
326+
_columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny<CancellationToken>()))
327+
.ReturnsAsync((Column?)null);
328+
_webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny<CancellationToken>()))
329+
.ReturnsAsync(new List<OutboundWebhookSubscription>());
330+
331+
var result = await _detector.DetectConflictsAsync(proposal.Id, _userId);
332+
333+
result.IsSuccess.Should().BeTrue();
334+
result.Value.Should().Contain(r =>
335+
r.Tone == ConflictTone.Warn
336+
&& r.Key == "missing-target-column");
337+
}
338+
339+
[Fact]
340+
public async Task DetectConflictsAsync_MoveOutAndIntoSameColumn_UsesNetProjectedWip()
341+
{
342+
var targetColumnId = Guid.NewGuid();
343+
var otherColumnId = Guid.NewGuid();
344+
var sourceColumnId = Guid.NewGuid();
345+
var leavingCardId = Guid.NewGuid();
346+
var enteringCardId = Guid.NewGuid();
347+
348+
var proposal = CreateProposal(_userId, _boardId);
349+
proposal.AddOperation(new AutomationProposalOperation(
350+
proposal.Id,
351+
0,
352+
"move",
353+
"card",
354+
$"{{\"targetColumnId\":\"{otherColumnId}\"}}",
355+
Guid.NewGuid().ToString(),
356+
leavingCardId.ToString()));
357+
proposal.AddOperation(new AutomationProposalOperation(
358+
proposal.Id,
359+
1,
360+
"move",
361+
"card",
362+
$"{{\"targetColumnId\":\"{targetColumnId}\"}}",
363+
Guid.NewGuid().ToString(),
364+
enteringCardId.ToString()));
365+
366+
_proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny<CancellationToken>()))
367+
.ReturnsAsync(proposal);
368+
369+
var targetColumn = new Column(_boardId, "In Progress", 1, wipLimit: 5);
370+
AddCardsToColumn(targetColumn, 5);
371+
var otherColumn = new Column(_boardId, "Done", 2, wipLimit: 10);
372+
373+
_columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(targetColumnId, It.IsAny<CancellationToken>()))
374+
.ReturnsAsync(targetColumn);
375+
_columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(otherColumnId, It.IsAny<CancellationToken>()))
376+
.ReturnsAsync(otherColumn);
377+
378+
_cardRepoMock.Setup(r => r.GetByIdAsync(leavingCardId, It.IsAny<CancellationToken>()))
379+
.ReturnsAsync(new Card(leavingCardId, _boardId, targetColumnId, "Leaving"));
380+
_cardRepoMock.Setup(r => r.GetByIdAsync(enteringCardId, It.IsAny<CancellationToken>()))
381+
.ReturnsAsync(new Card(enteringCardId, _boardId, sourceColumnId, "Entering"));
382+
SetupEmptySecondaryChecks(proposal);
383+
384+
var result = await _detector.DetectConflictsAsync(proposal.Id, _userId);
385+
386+
result.IsSuccess.Should().BeTrue();
387+
result.Value.Should().NotContain(r =>
388+
r.Key == "wip-limit"
389+
&& r.Value.Contains("In Progress", StringComparison.Ordinal));
390+
}
391+
316392
#endregion
317393

318394
#region Warn: Duplicate Pending Proposals

0 commit comments

Comments
 (0)