diff --git a/QuickFiler.Test/Controllers/QfcDatamodelTests.cs b/QuickFiler.Test/Controllers/QfcDatamodelTests.cs
index eac26b5f0..a4b3beeef 100644
--- a/QuickFiler.Test/Controllers/QfcDatamodelTests.cs
+++ b/QuickFiler.Test/Controllers/QfcDatamodelTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Reflection;
@@ -313,5 +313,79 @@ public async Task WaitForQueue_WhenWorkerBusyAndQueueShort_AwaitsInjectedTwoHund
}
#endregion Issue #222 — Injectable time/delay seam
+
+ #region Issue #446 — Top-folder propagation from the master-queue admission scorer
+
+ ///
+ /// Issue #446. ScoreRemainingQueueMailItemAsync must surface BOTH halves of the
+ /// scorer result. The scoring service already returns a (Score, TopFolder) pair, but
+ /// the datamodel discards the folder, so a later consumer has to re-score the same item.
+ /// Scoring is driven through the ScoringServiceFactory seam added by [P1-T5] so no
+ /// live Outlook COM is touched, as .claude/rules/general-unit-test.md UT4 requires.
+ ///
+ [TestMethod]
+ public async Task ScoreRemainingQueueMailItemAsync_ReturnsScoreAndTopFolder()
+ {
+ // Arrange
+ var model = CreateUninitializedDatamodel();
+ var mailItem = new Mock().Object;
+ var globals = new Mock(MockBehavior.Strict);
+
+ const long ExpectedScore = 875L;
+ const string ExpectedTopFolder = @"Inbox\Projects\Alpha";
+
+ var scoringService = new Mock(MockBehavior.Strict);
+ scoringService
+ .Setup(x =>
+ x.ScoreAsync(
+ mailItem,
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync((ExpectedScore, ExpectedTopFolder));
+
+ SetPrivateField(model, "_globals", globals.Object);
+ model.ScoringServiceFactory = () => scoringService.Object;
+
+ // Act
+ (long Score, string TopFolder) result = await InvokeScoreRemainingQueueMailItemAsync(
+ model,
+ mailItem
+ );
+
+ // Assert
+ result
+ .Score.Should()
+ .Be(ExpectedScore, "the score half of the scorer result is already propagated");
+ result
+ .TopFolder.Should()
+ .Be(
+ ExpectedTopFolder,
+ "the top-ranked folder the scorer already computed must reach the caller "
+ + "instead of being discarded and re-derived downstream"
+ );
+ }
+
+ private static Task<(long Score, string TopFolder)> InvokeScoreRemainingQueueMailItemAsync(
+ QfcDatamodel model,
+ MailItem mailItem
+ )
+ {
+ var method = typeof(QfcDatamodel).GetMethod(
+ "ScoreRemainingQueueMailItemAsync",
+ NonPublicInstance
+ );
+ method
+ .Should()
+ .NotBeNull(
+ "ScoreRemainingQueueMailItemAsync should exist on QfcDatamodel as a private "
+ + "instance method"
+ );
+ return (Task<(long Score, string TopFolder)>)
+ method.Invoke(model, new object[] { mailItem, CancellationToken.None });
+ }
+
+ #endregion Issue #446 — Top-folder propagation from the master-queue admission scorer
}
}
diff --git a/QuickFiler.Test/Controllers/QfcFormControllerSeamTests.cs b/QuickFiler.Test/Controllers/QfcFormControllerSeamTests.cs
index c72356005..90ab0f4ed 100644
--- a/QuickFiler.Test/Controllers/QfcFormControllerSeamTests.cs
+++ b/QuickFiler.Test/Controllers/QfcFormControllerSeamTests.cs
@@ -1,10 +1,14 @@
-using System;
+using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
+using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using FluentAssertions;
+using Microsoft.Extensions.Time.Testing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using QuickFiler.Controllers;
@@ -34,53 +38,27 @@ public class QfcFormControllerSeamTests
private CancellationToken _token;
private QfcFormController _controller;
- private T GetPrivateField(object obj, string fieldName)
- {
- var field = obj.GetType()
- .GetField(
- fieldName,
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- );
- return (T)field.GetValue(obj);
- }
+ private const BindingFlags PrivateInstance = BindingFlags.NonPublic | BindingFlags.Instance;
- private void SetPrivateField(object obj, string fieldName, T value)
- {
- var field = obj.GetType()
- .GetField(
- fieldName,
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- );
- field.SetValue(obj, value);
- }
+ private T GetPrivateField(object obj, string fieldName) =>
+ (T)obj.GetType().GetField(fieldName, PrivateInstance).GetValue(obj);
- private static string ReadControllerSource(string fileName)
- {
- return File.ReadAllText(ResolveRepositoryPath("QuickFiler", "Controllers", fileName));
- }
+ private void SetPrivateField(object obj, string fieldName, T value) =>
+ obj.GetType().GetField(fieldName, PrivateInstance).SetValue(obj, value);
+
+ private static string ReadControllerSource(string fileName) =>
+ File.ReadAllText(ResolveRepositoryPath("QuickFiler", "Controllers", fileName));
private static string ResolveRepositoryPath(params string[] pathParts)
{
- var directory = new DirectoryInfo(AppContext.BaseDirectory);
- while (
- directory != null
- && !Directory.Exists(Path.Combine(directory.FullName, "QuickFiler"))
- )
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "QuickFiler")))
{
- directory = directory.Parent;
+ dir = dir.Parent;
}
- directory.Should().NotBeNull("source-inspection tests must run under the repository");
-
- var resolvedPath = directory.FullName;
- foreach (var pathPart in pathParts)
- {
- resolvedPath = Path.Combine(resolvedPath, pathPart);
- }
-
- return resolvedPath;
+ dir.Should().NotBeNull("source-inspection tests must run under the repository");
+ return pathParts.Aggregate(dir.FullName, Path.Combine);
}
private QfcFormController CreateQfcFormController()
@@ -374,5 +352,145 @@ public void LoadItemsAsync_MailItemPath_DoesNotApplyPostDisplayHighConfidenceRem
}
#endregion Seam D — CaptureItemSettings via CaptureTlpCellStates
+
+ #region Issue #448 — undo-consumer termination and idle timer
+
+ /// A that counts the delays it is asked for.
+ private sealed class CountingTimeProvider : FakeTimeProvider
+ {
+ public int DelayRequests { get; private set; }
+
+ public override ITimer CreateTimer(TimerCallback cb, object s, TimeSpan due, TimeSpan p)
+ {
+ DelayRequests++;
+ return base.CreateTimer(cb, s, due, p);
+ }
+ }
+
+ ///
+ /// Runs the undo consumer inline against , with a processor seam so
+ /// no live COM or dispatcher call is made (UT4, D-Plan-3), and optional pre-queued items.
+ ///
+ private QfcFormController ArrangeUndoConsumer(
+ TimeProvider clock,
+ Func processor = null,
+ int queuedItems = 0
+ )
+ {
+ QfcFormController c = CreateQfcFormController();
+ c.TimeProvider = clock;
+ c.UndoConsumerStarter = body => body();
+ c.UndoItemProcessor = processor ?? (_ => Task.CompletedTask);
+ var q = GetPrivateField>(c, "_undoQueue");
+ while (queuedItems-- > 0)
+ {
+ q.Add(new Mock().Object);
+ }
+ return c;
+ }
+
+ ///
+ /// Issue #448. Idle iterations must wait through the injected clock, or the threshold cannot
+ /// be driven. The task is deliberately not awaited: the pre-fix loop never ends (D5).
+ ///
+ [TestMethod]
+ public void UndoConsumer_EveryIdleIteration_InvokesTimeProviderDelay()
+ {
+ // Arrange
+ var clock = new CountingTimeProvider();
+ QfcFormController controller = ArrangeUndoConsumer(clock);
+ // Act — runs inline until the first idle wait, then returns.
+ _ = controller.UndoConsumerStarter(controller.UndoConsumer);
+ // Assert
+ clock.DelayRequests.Should().BeGreaterThanOrEqualTo(1, "idle waits use the seam");
+ }
+
+ ///
+ /// Issue #448. An idle consumer past the threshold must terminate; before the rewrite the
+ /// exit flag fed a disjunction that kept the loop alive for the session.
+ ///
+ [TestMethod]
+ [Timeout(10000)]
+ public async Task UndoConsumer_IdleBeyondThreshold_Completes()
+ {
+ // Arrange
+ var clock = new FakeTimeProvider();
+ QfcFormController controller = ArrangeUndoConsumer(clock);
+ // Act
+ Task consumer = controller.UndoConsumerStarter(controller.UndoConsumer);
+ clock.Advance(TimeSpan.FromSeconds(11));
+ await consumer.ConfigureAwait(false);
+ // Assert
+ consumer.Status.Should().Be(TaskStatus.RanToCompletion, "an idle consumer must exit");
+ }
+
+ ///
+ /// Issue #448. The threshold measures time since the last take, not since start. Three takes
+ /// advance the clock six seconds each (eighteen in aggregate, past the ten-second threshold)
+ /// while every idle gap stays at zero, so the consumer drains and then waits; a session timer
+ /// exits instead, which the completion flag and the delay count detect.
+ ///
+ [TestMethod]
+ [Timeout(10000)]
+ public async Task UndoConsumer_SuccessfulTake_ResetsIdleTimer()
+ {
+ // Arrange — the fake processor keeps live COM and the dispatcher out (UT4).
+ var clock = new CountingTimeProvider();
+ var processed = new List();
+ QfcFormController controller = ArrangeUndoConsumer(
+ clock,
+ item =>
+ {
+ processed.Add(item);
+ clock.Advance(TimeSpan.FromSeconds(6));
+ return Task.CompletedTask;
+ },
+ queuedItems: 3
+ );
+ // Act — drains all three takes inline, then parks on its first idle wait.
+ Task consumer = controller.UndoConsumerStarter(controller.UndoConsumer);
+ // Assert
+ processed.Should().HaveCount(3, "18 s of takes must not end the consumer");
+ consumer.IsCompleted.Should().BeFalse("the consumer parked instead of exiting");
+ clock.DelayRequests.Should().Be(1, "it took the idle branch, not the exit branch");
+ // Idle past the threshold measured from the last take does end it.
+ clock.Advance(TimeSpan.FromSeconds(11));
+ await consumer.ConfigureAwait(false);
+ consumer.Status.Should().Be(TaskStatus.RanToCompletion);
+ }
+
+ ///
+ /// Issue #448. Every exit path must clear _undoConsumerTask so a later
+ /// UndoDialog() starts a fresh consumer. A sentinel is planted first, so a path that
+ /// fails to clear the field leaves the sentinel behind and the assertion fails.
+ ///
+ [TestMethod]
+ [Timeout(10000)]
+ public async Task UndoConsumer_OnExit_ResetsUndoConsumerTask()
+ {
+ // Arrange — one consumer per exit path; the throwing processor stands in for the
+ // exception disposing _undoQueue mid-take produces. The sentinel makes the assertion
+ // real: without it the field starts null and every path would pass vacuously.
+ var idleClock = new FakeTimeProvider();
+ QfcFormController idle = ArrangeUndoConsumer(idleClock);
+ QfcFormController bad = ArrangeUndoConsumer(
+ new FakeTimeProvider(),
+ _ => throw new InvalidOperationException("undo failed"),
+ queuedItems: 1
+ );
+ SetPrivateField(idle, "_undoConsumerTask", Task.CompletedTask);
+ SetPrivateField(bad, "_undoConsumerTask", Task.CompletedTask);
+ // Act — the idle exit, then the exception exit.
+ Task idleConsumer = idle.UndoConsumerStarter(idle.UndoConsumer);
+ idleClock.Advance(TimeSpan.FromSeconds(11));
+ await idleConsumer.ConfigureAwait(false);
+ Func act = () => bad.UndoConsumerStarter(bad.UndoConsumer);
+ await act.Should().ThrowAsync().ConfigureAwait(false);
+ // Assert — both exit paths cleared the planted sentinel.
+ GetPrivateField(idle, "_undoConsumerTask").Should().BeNull("idle path clears");
+ GetPrivateField(bad, "_undoConsumerTask").Should().BeNull("throw path clears");
+ }
+
+ #endregion Issue #448 — undo-consumer termination and idle timer
}
}
diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs b/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs
index f39627020..5d532f645 100644
--- a/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs
+++ b/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs
@@ -1,10 +1,12 @@
-using System;
+using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
+using System.Linq.Expressions;
+using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
@@ -74,20 +76,59 @@ private void SetupQfSettings(bool highConfidenceEnabled, double threshold)
this._mockApplicationGlobals.SetupGet(x => x.QfSettings).Returns(qfSettings.Object);
}
- [TestMethod]
- public async Task IterateQueueAsync_DataModelComplete()
+ /// Matcher accepting any value; the default for ArrangeIterate.
+ private static Expression> AnyValue => x => true;
+
+ ///
+ /// Shared arrangement for the queue-iteration tests; returns the mocks it wires into
+ /// _controller. The dequeue matchers are expressions, not values, so a pinned call
+ /// site stays pinned (q => q == 8) instead of widening to It.IsAny;
+ /// outcome replaces the dequeue result and is how the exception tests throw.
+ ///
+ private (
+ Mock DataModel,
+ Mock Queue,
+ Mock FormController,
+ Mock Groups
+ ) ArrangeIterate(
+ Expression> quantity = null,
+ Expression> timeOut = null,
+ bool complete = false,
+ IList dequeued = null,
+ int itemsPerIteration = 8,
+ QfcDequeueStop stop = QfcDequeueStop.QuantitySatisfied,
+ Func> outcome = null
+ )
{
- // Arrange
- var mockDataModel = new Mock();
- mockDataModel.Setup(m => m.Complete).Returns(true);
- mockDataModel
- .Setup(m => m.DequeueNextItemGroupAsync(It.IsAny(), It.IsAny()))
- .Returns(Task.FromResult((IList)new List()));
- var mockQfcQueue = new Mock();
- mockQfcQueue
+ IList batch = dequeued ?? new List();
+ quantity = quantity ?? AnyValue;
+ timeOut = timeOut ?? AnyValue;
+ if (outcome == null)
+ {
+ outcome = () => Task.FromResult(new QfcDequeueBatch(batch, null, stop));
+ }
+
+ var dataModel = new Mock();
+ dataModel.Setup(m => m.Complete).Returns(complete);
+ dataModel
+ .Setup(m => m.DequeueNextItemGroupAsync(It.Is(quantity), It.Is(timeOut)))
+ .Returns(Task.FromResult(batch));
+ dataModel
+ .Setup(m =>
+ m.DequeueNextItemGroupWithOutcomeAsync(
+ It.Is(quantity),
+ It.Is(timeOut),
+ It.IsAny(),
+ It.IsAny>()
+ )
+ )
+ .Returns(outcome);
+
+ var queue = new Mock();
+ queue
.Setup(m => m.CompleteAddingAsync(It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
- mockQfcQueue
+ queue
.Setup(m =>
m.EnqueueAsync(
It.IsAny>(),
@@ -95,90 +136,98 @@ public async Task IterateQueueAsync_DataModelComplete()
)
)
.Returns(Task.CompletedTask);
- _controller.DataModel = mockDataModel.Object;
- _controller.QfcQueue = mockQfcQueue.Object;
- // Act
- await _controller.IterateQueueAsync();
+ var groups = new Mock();
+ var formController = new Mock();
+ formController.SetupGet(m => m.ItemsPerIteration).Returns(itemsPerIteration);
+ formController.Setup(m => m.Groups).Returns(groups.Object);
- // Assert
- mockDataModel.Verify(
- m => m.DequeueNextItemGroupAsync(It.IsAny(), It.IsAny()),
- Times.Never
- );
- mockQfcQueue.Verify(
+ _controller.DataModel = dataModel.Object;
+ _controller.QfcQueue = queue.Object;
+ SetPrivateField("_formController", formController.Object);
+
+ return (dataModel, queue, formController, groups);
+ }
+
+ /// Assigns a private instance field on the controller under test.
+ private void SetPrivateField(string name, object value) =>
+ _controller
+ .GetType()
+ .GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)
+ .SetValue(_controller, value);
+
+ /// Verifies the complete-adding invocation count on the queue mock.
+ private static void VerifyCompleteAdding(
+ Mock queue,
+ Func times,
+ string because = null
+ ) =>
+ queue.Verify(
m => m.CompleteAddingAsync(It.IsAny(), It.IsAny()),
- Times.Never
+ times,
+ because
);
- mockQfcQueue.Verify(
+
+ /// Verifies the unconstrained enqueue invocation count on the queue mock.
+ private static void VerifyEnqueue(Mock queue, Func times) =>
+ queue.Verify(
m =>
m.EnqueueAsync(
It.IsAny>(),
It.IsAny()
),
- Times.Never
+ times
);
- }
[TestMethod]
- public async Task IterateQueueAsync_QueueEmpty()
+ public async Task IterateQueueAsync_DataModelComplete()
{
// Arrange
- var mockDataModel = new Mock();
- mockDataModel.Setup(m => m.Complete).Returns(false);
- mockDataModel
- .Setup(m => m.DequeueNextItemGroupAsync(It.IsAny(), It.IsAny()))
- .Returns(Task.FromResult((IList)new List()));
- _controller.DataModel = mockDataModel.Object;
-
- var mockQfcQueue = new Mock();
- mockQfcQueue
- .Setup(m => m.CompleteAddingAsync(It.IsAny(), It.IsAny()))
- .Returns(Task.CompletedTask);
- mockQfcQueue
- .Setup(m =>
- m.EnqueueAsync(
- It.IsAny>(),
- It.IsAny()
- )
- )
- .Returns(Task.CompletedTask);
- _controller.QfcQueue = mockQfcQueue.Object;
-
- // Mock the QfcFormController
- var mockFormController = new Mock();
- mockFormController.Setup(m => m.ItemsPerIteration).Returns(8);
- var mockQfcCollectionController = new Mock();
- mockFormController.Setup(m => m.Groups).Returns(mockQfcCollectionController.Object);
- _controller
- .GetType()
- .GetField(
- "_formController",
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- )
- .SetValue(_controller, mockFormController.Object);
+ var (mockDataModel, mockQfcQueue, _, _) = ArrangeIterate(complete: true);
// Act
await _controller.IterateQueueAsync();
// Assert
mockDataModel.Verify(
- m => m.DequeueNextItemGroupAsync(It.IsAny(), It.IsAny()),
- Times.Once
+ m =>
+ m.DequeueNextItemGroupWithOutcomeAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ ),
+ Times.Never
);
- mockQfcQueue.Verify(
- m => m.CompleteAddingAsync(It.IsAny(), It.IsAny()),
- Times.Once
+ VerifyCompleteAdding(mockQfcQueue, Times.Never);
+ VerifyEnqueue(mockQfcQueue, Times.Never);
+ }
+
+ [TestMethod]
+ public async Task IterateQueueAsync_QueueEmpty()
+ {
+ // Arrange — issue #446 made an empty batch insufficient on its own to close the queue,
+ // so the drained-source stop is now stated explicitly. The assertions are unchanged.
+ var (mockDataModel, mockQfcQueue, _, _) = ArrangeIterate(
+ stop: QfcDequeueStop.SourceExhausted
);
- mockQfcQueue.Verify(
+
+ // Act
+ await _controller.IterateQueueAsync();
+
+ // Assert
+ mockDataModel.Verify(
m =>
- m.EnqueueAsync(
- It.IsAny>(),
- It.IsAny()
+ m.DequeueNextItemGroupWithOutcomeAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
),
- Times.Never
+ Times.Once
);
+ VerifyCompleteAdding(mockQfcQueue, Times.Once);
+ VerifyEnqueue(mockQfcQueue, Times.Never);
}
[TestMethod]
@@ -186,10 +235,6 @@ public async Task IterateQueueAsync_Queue2()
{
// Arrange
- // Mock DataModel
- var mockDataModel = new Mock();
- mockDataModel.Setup(m => m.Complete).Returns(false);
-
// Setup DequeueNextItemGroupAsync to return 2 mail items
var mockMailItem = new Mock();
IList mailItems = new List
@@ -197,101 +242,38 @@ public async Task IterateQueueAsync_Queue2()
mockMailItem.Object,
mockMailItem.Object,
};
- mockDataModel
- .Setup(m => m.DequeueNextItemGroupAsync(It.IsAny(), It.IsAny()))
- .Returns(Task.FromResult(mailItems));
-
- // Set the DataModel in the controller to the mock
- _controller.DataModel = mockDataModel.Object;
-
- // Mock the QfcQueue
- var mockQfcQueue = new Mock();
- mockQfcQueue
- .Setup(m => m.CompleteAddingAsync(It.IsAny(), It.IsAny()))
- .Returns(Task.CompletedTask);
- mockQfcQueue
- .Setup(m =>
- m.EnqueueAsync(
- It.IsAny>(),
- It.IsAny()
- )
- )
- .Returns(Task.CompletedTask);
- _controller.QfcQueue = mockQfcQueue.Object;
-
- // Mock the QfcFormController
- var mockFormController = new Mock();
- mockFormController.Setup(m => m.ItemsPerIteration).Returns(8);
- var mockQfcCollectionController = new Mock();
- mockFormController.Setup(m => m.Groups).Returns(mockQfcCollectionController.Object);
- _controller
- .GetType()
- .GetField(
- "_formController",
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- )
- .SetValue(_controller, mockFormController.Object);
+ var (mockDataModel, mockQfcQueue, _, _) = ArrangeIterate(dequeued: mailItems);
// Act
await _controller.IterateQueueAsync();
// Assert
mockDataModel.Verify(
- m => m.DequeueNextItemGroupAsync(It.IsAny(), It.IsAny()),
- Times.Once
- );
- mockQfcQueue.Verify(
- m => m.CompleteAddingAsync(It.IsAny(), It.IsAny()),
- Times.Never
- );
- mockQfcQueue.Verify(
m =>
- m.EnqueueAsync(
- It.IsAny>(),
- It.IsAny()
+ m.DequeueNextItemGroupWithOutcomeAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
),
Times.Once
);
+ VerifyCompleteAdding(mockQfcQueue, Times.Never);
+ VerifyEnqueue(mockQfcQueue, Times.Once);
}
[TestMethod]
public async Task IterateQueueAsync_WhenDequeueReturnsFullQualifiedPage_EnqueuesAllItems()
{
- var mockDataModel = new Mock();
- mockDataModel.Setup(m => m.Complete).Returns(false);
var mailItems = Enumerable
.Range(0, 8)
.Select(_ => new Mock().Object)
.ToList();
- mockDataModel
- .Setup(m => m.DequeueNextItemGroupAsync(8, 2000))
- .Returns(Task.FromResult((IList)mailItems));
- _controller.DataModel = mockDataModel.Object;
-
- var mockQfcQueue = new Mock();
- mockQfcQueue
- .Setup(m =>
- m.EnqueueAsync(
- It.Is>(items => items.SequenceEqual(mailItems)),
- It.IsAny()
- )
- )
- .Returns(Task.CompletedTask);
- _controller.QfcQueue = mockQfcQueue.Object;
-
- var mockFormController = new Mock();
- mockFormController.Setup(m => m.ItemsPerIteration).Returns(8);
- var mockQfcCollectionController = new Mock();
- mockFormController.Setup(m => m.Groups).Returns(mockQfcCollectionController.Object);
- _controller
- .GetType()
- .GetField(
- "_formController",
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- )
- .SetValue(_controller, mockFormController.Object);
+ var (_, mockQfcQueue, _, mockQfcCollectionController) = ArrangeIterate(
+ q => q == 8,
+ t => t == 2000,
+ dequeued: mailItems
+ );
await _controller.IterateQueueAsync();
@@ -303,10 +285,7 @@ public async Task IterateQueueAsync_WhenDequeueReturnsFullQualifiedPage_Enqueues
),
Times.Once
);
- mockQfcQueue.Verify(
- m => m.CompleteAddingAsync(It.IsAny(), It.IsAny()),
- Times.Never
- );
+ VerifyCompleteAdding(mockQfcQueue, Times.Never);
}
[TestMethod]
@@ -327,14 +306,7 @@ public void Iterate_ExecutesCorrectly()
SetupQfSettings(highConfidenceEnabled: false, threshold: 0.90);
var mockFormController = new Mock();
- _controller
- .GetType()
- .GetField(
- "_formController",
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- )
- .SetValue(_controller, mockFormController.Object);
+ SetPrivateField("_formController", mockFormController.Object);
// Act
_controller.Iterate();
@@ -366,24 +338,12 @@ public void Iterate_HighConfidenceEnabled_DoesNotLoadDirectSynchronousBatch()
SetupQfSettings(highConfidenceEnabled: true, threshold: 0.90);
- var mockDataModel = new Mock();
+ var (mockDataModel, _, mockFormController, _) = ArrangeIterate(
+ q => q == itemsPerIteration,
+ itemsPerIteration: itemsPerIteration
+ );
mockDataModel.Setup(m => m.DequeueNextItemGroup(It.IsAny())).Returns(directBatch);
- mockDataModel
- .Setup(m => m.DequeueNextItemGroupAsync(itemsPerIteration, It.IsAny()))
- .ReturnsAsync(new List());
- _controller.DataModel = mockDataModel.Object;
-
- var mockFormController = new Mock();
- mockFormController.SetupGet(m => m.ItemsPerIteration).Returns(itemsPerIteration);
mockFormController.Setup(m => m.LoadItems(It.IsAny>()));
- _controller
- .GetType()
- .GetField(
- "_formController",
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- )
- .SetValue(_controller, mockFormController.Object);
// Act
_controller.Iterate();
@@ -410,14 +370,7 @@ public void Iterate2_ExecutesCorrectly()
var mockQfcQueue = new Mock();
var mockFormController = new Mock();
_controller.QfcQueue = mockQfcQueue.Object;
- _controller
- .GetType()
- .GetField(
- "_formController",
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- )
- .SetValue(_controller, mockFormController.Object);
+ SetPrivateField("_formController", mockFormController.Object);
_controller.DataModel = mockDataModel.Object;
// Act
@@ -436,14 +389,7 @@ public void SwapStopWatch_ExecutesCorrectly()
{
// Arrange
var stopWatch = new Stopwatch();
- _controller
- .GetType()
- .GetField(
- "_stopWatch",
- System.Reflection.BindingFlags.NonPublic
- | System.Reflection.BindingFlags.Instance
- )
- .SetValue(_controller, stopWatch);
+ SetPrivateField("_stopWatch", stopWatch);
// Act
_controller.SwapStopWatch();
@@ -460,5 +406,92 @@ public void SwapStopWatch_ExecutesCorrectly()
.GetValue(_controller) as Stopwatch;
Assert.AreEqual(stopWatch, actual);
}
+
+ ///
+ /// Issue #446. A deadline-expired empty batch is not source exhaustion — the master queue
+ /// may still hold unscanned items — so it must not irreversibly close the UI queue.
+ ///
+ [TestMethod]
+ public async Task IterateQueueAsync_EmptyBatchWithDeadlineExpired_DoesNotCompleteAdding()
+ {
+ var (_, queue, _, _) = ArrangeIterate(stop: QfcDequeueStop.DeadlineExpired);
+
+ await _controller.IterateQueueAsync();
+
+ VerifyCompleteAdding(
+ queue,
+ Times.Never,
+ "a deadline-bounded empty batch must not close the queue"
+ );
+ }
+
+ ///
+ /// Issue #446 negative control for AC2: a genuinely drained source SHOULD close the queue,
+ /// so a fix that merely stopped calling CompleteAddingAsync would break this test.
+ ///
+ [TestMethod]
+ public async Task IterateQueueAsync_EmptyBatchWithSourceExhausted_CompletesAddingOnce()
+ {
+ var (_, queue, _, _) = ArrangeIterate(stop: QfcDequeueStop.SourceExhausted);
+
+ await _controller.IterateQueueAsync();
+
+ VerifyCompleteAdding(
+ queue,
+ Times.Once,
+ "a drained source is the one empty-batch case that may close the queue"
+ );
+ }
+
+ ///
+ /// Issue #446 coverage: an OperationCanceledException from the dequeue is swallowed.
+ ///
+ [TestMethod]
+ public async Task IterateQueueAsync_DequeueThrowsOperationCanceled_SwallowsAndReturns()
+ {
+ ArrangeIterate(outcome: () => throw new OperationCanceledException());
+
+ Func act = () => _controller.IterateQueueAsync();
+
+ await act.Should().NotThrowAsync("a cancelled dequeue must not surface to the caller");
+ }
+
+ ///
+ /// Issue #446 coverage: a fault raised while cancellation is pending is swallowed. The
+ /// token is cancelled from INSIDE the dequeue callback because the entry-guard
+ /// Token.ThrowIfCancellationRequested() sits outside the try block, so a token
+ /// already cancelled at entry escapes uncaught and never reaches this branch.
+ ///
+ [TestMethod]
+ public async Task IterateQueueAsync_DequeueThrowsWhenTokenCancelled_SwallowsAndReturns()
+ {
+ var source = new CancellationTokenSource();
+ SetPrivateField("_token", source.Token);
+ ArrangeIterate(outcome: () =>
+ {
+ source.Cancel();
+ throw new InvalidOperationException("dequeue failed");
+ });
+
+ Func act = () => _controller.IterateQueueAsync();
+
+ await act.Should().NotThrowAsync("a fault raised after cancellation is a cancellation");
+ }
+
+ ///
+ /// Issue #446 coverage: a fault with no cancellation pending is rethrown, not swallowed.
+ ///
+ [TestMethod]
+ public async Task IterateQueueAsync_DequeueThrowsWhenTokenNotCancelled_Rethrows()
+ {
+ var fault = new InvalidOperationException("dequeue failed");
+ ArrangeIterate(outcome: () => throw fault);
+
+ Func act = () => _controller.IterateQueueAsync();
+
+ (await act.Should().ThrowAsync())
+ .Which.Should()
+ .BeSameAs(fault);
+ }
}
}
diff --git a/QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs b/QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs
index 1d808ce83..97e145f18 100644
--- a/QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs
+++ b/QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Reflection;
@@ -6,6 +6,7 @@
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
+using Microsoft.Extensions.Time.Testing;
using Microsoft.Office.Interop.Outlook;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
@@ -132,5 +133,130 @@ public async Task DequeueNextItemGroupAsync_HighConfidenceDisabled_PreservesDire
moveMonitor.Verify(x => x.UnhookItem(first), Times.Once);
moveMonitor.Verify(x => x.UnhookItem(second), Times.Once);
}
+
+ ///
+ /// Issue #426. A candidate the high-confidence dequeue gate discards has already been
+ /// removed from the master queue and never reaches UnhookDequeuedNodes, so its
+ /// EmailMoveMonitor hook and its live COM reference are retained for the session.
+ /// The datamodel must release the hook through its OWN monitor instance exactly once.
+ /// Scoring is driven through the ScoringServiceFactory seam so no live Outlook COM
+ /// is touched.
+ ///
+ [TestMethod]
+ public async Task DequeueNextItemGroupAsync_HighConfidenceRejectedItem_UnhooksFromMoveMonitor()
+ {
+ // Arrange
+ var model = CreateUninitializedDatamodel();
+ var rejectedItem = new Mock().Object;
+ var masterQueue = new LockingLinkedList();
+ masterQueue.AddLast(rejectedItem);
+
+ var settings = new Mock(MockBehavior.Strict);
+ settings.SetupGet(x => x.HighConfidenceModeEnabled).Returns(true);
+ settings.SetupGet(x => x.HighConfidenceThreshold).Returns(0.90);
+ var globals = new Mock(MockBehavior.Strict);
+ globals.SetupGet(x => x.QfSettings).Returns(settings.Object);
+
+ var scoringService = new Mock(MockBehavior.Strict);
+ scoringService
+ .Setup(x =>
+ x.ScoreAsync(
+ rejectedItem,
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync((100L, string.Empty));
+
+ var moveMonitor = new Mock(MockBehavior.Strict);
+ moveMonitor.Setup(x => x.UnhookItem(rejectedItem));
+
+ SetPrivateField(model, "_globals", globals.Object);
+ SetPrivateField(model, "_masterQueue", masterQueue);
+ SetPrivateField(model, "_moveMonitor", moveMonitor.Object);
+ SetPrivateField(model, "_worker", new BackgroundWorker());
+ model.ScoringServiceFactory = () => scoringService.Object;
+
+ // Act
+ IList result = await model.DequeueNextItemGroupAsync(1, 0);
+
+ // Assert
+ result.Should().BeEmpty("the drop-on-reject contract is unchanged");
+ masterQueue.Count.Should().Be(0, "the rejected candidate is still removed from source");
+ moveMonitor.Verify(
+ x => x.UnhookItem(rejectedItem),
+ Times.Once,
+ "the datamodel must release the rejected candidate's monitor hook exactly once"
+ );
+ }
+
+ ///
+ /// Issue #446. A gate result produced by first-batch deadline expiry must be projected
+ /// through the datamodel as QfcDequeueStop.DeadlineExpired rather than folded into
+ /// the generic quantity-satisfied outcome, otherwise the caller cannot tell a
+ /// deadline-bounded empty batch from genuine exhaustion. Driven by
+ /// : every score consumes one second of a three-second budget
+ /// and nothing qualifies, so the deadline exit is the one the gate takes.
+ ///
+ [TestMethod]
+ public async Task DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop()
+ {
+ // Arrange
+ var model = CreateUninitializedDatamodel();
+ var fake = new FakeTimeProvider();
+ model.TimeProvider = fake;
+
+ var masterQueue = new LockingLinkedList();
+ for (int i = 0; i < 10; i++)
+ {
+ masterQueue.AddLast(new Mock().Object);
+ }
+
+ var settings = new Mock(MockBehavior.Strict);
+ settings.SetupGet(x => x.HighConfidenceModeEnabled).Returns(true);
+ settings.SetupGet(x => x.HighConfidenceThreshold).Returns(0.90);
+ var globals = new Mock(MockBehavior.Strict);
+ globals.SetupGet(x => x.QfSettings).Returns(settings.Object);
+
+ var scoringService = new Mock(MockBehavior.Strict);
+ scoringService
+ .Setup(x =>
+ x.ScoreAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .Returns(() =>
+ {
+ fake.Advance(TimeSpan.FromSeconds(1));
+ return Task.FromResult((100L, string.Empty));
+ });
+
+ var moveMonitor = new Mock(MockBehavior.Strict);
+ SetPrivateField(model, "_globals", globals.Object);
+ SetPrivateField(model, "_masterQueue", masterQueue);
+ SetPrivateField(model, "_moveMonitor", moveMonitor.Object);
+ SetPrivateField(model, "_worker", new BackgroundWorker());
+ SetPrivateField(model, "_remainingLoadActive", true);
+ model.ScoringServiceFactory = () => scoringService.Object;
+
+ // Act
+ QfcDequeueBatch batch = await model.DequeueNextItemGroupWithOutcomeAsync(
+ 1,
+ 0,
+ TimeSpan.FromSeconds(3),
+ null
+ );
+
+ // Assert
+ batch.Items.Should().BeEmpty("no candidate qualified before the deadline");
+ batch
+ .Stop.Should()
+ .Be(
+ QfcDequeueStop.DeadlineExpired,
+ "a deadline-bounded empty batch must not be reported as quantity satisfaction"
+ );
+ }
}
}
diff --git a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs
index 69c772b4b..296a5d951 100644
--- a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs
+++ b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs
@@ -59,7 +59,7 @@ out Func takeCounter
(mail, token) =>
{
fakeTime.Advance(TimeSpan.FromSeconds(1));
- return Task.FromResult(100L);
+ return Task.FromResult((100L, ""));
},
threshold: 0.90,
timeProvider: fakeTime,
@@ -104,7 +104,7 @@ public async Task DequeueAsync_LowYieldStream_StopsScanningAtDefaultFirstBatchDe
// Each score costs a full second of the first-batch budget.
fakeTime.Advance(TimeSpan.FromSeconds(1));
- return Task.FromResult(score);
+ return Task.FromResult((score, ""));
},
threshold: 0.90,
timeProvider: fakeTime,
@@ -153,7 +153,7 @@ public async Task DequeueAsync_DeadlineExpiresDuringInFlightScore_IncludesFinalA
MailItem never = CreateMailItem("never-scanned", "entry-never-scanned");
var source = new Queue(new[] { inFlight, never });
var fakeTime = new FakeTimeProvider();
- var scoreGate = new TaskCompletionSource();
+ var scoreGate = new TaskCompletionSource<(long Score, string TopFolder)>();
var takeCount = 0;
object gate = CreateGate(
@@ -163,7 +163,7 @@ public async Task DequeueAsync_DeadlineExpiresDuringInFlightScore_IncludesFinalA
return source.Count == 0 ? null : source.Dequeue();
},
(mail, token) =>
- ReferenceEquals(mail, inFlight) ? scoreGate.Task : Task.FromResult(950L),
+ ReferenceEquals(mail, inFlight) ? scoreGate.Task : Task.FromResult((950L, "")),
threshold: 0.90,
timeProvider: fakeTime,
sourceActive: () => false,
@@ -177,7 +177,7 @@ public async Task DequeueAsync_DeadlineExpiresDuringInFlightScore_IncludesFinalA
fakeTime.Advance(TimeSpan.FromSeconds(6));
pending.IsCompleted.Should().BeFalse("expiry must not abandon the in-flight score");
- scoreGate.SetResult(950L);
+ scoreGate.SetResult((950L, ""));
IList result = await pending;
// Assert
@@ -242,7 +242,7 @@ public async Task DequeueAsync_QuantitySatisfiedBeforeExpiry_ReturnsUnchangedBat
takeCount++;
return source.Count == 0 ? null : source.Dequeue();
},
- (mail, token) => Task.FromResult(950L),
+ (mail, token) => Task.FromResult((950L, "")),
threshold: 0.90,
timeProvider: fakeTime,
sourceActive: () => false
@@ -286,7 +286,7 @@ public async Task DequeueAsync_DisabledSentinel_ReproducesUnboundedPreChangeBeha
(mail, token) =>
{
fakeTime.Advance(TimeSpan.FromSeconds(1));
- return Task.FromResult(ReferenceEquals(mail, qualifying) ? 950L : 100L);
+ return Task.FromResult((ReferenceEquals(mail, qualifying) ? 950L : 100L, ""));
},
threshold: 0.90,
timeProvider: fakeTime,
@@ -321,7 +321,7 @@ public void Constructor_NonPositiveNonSentinelDeadline_IsRejectedByGuardClause()
System.Action act = () =>
CreateGate(
() => null,
- (mail, token) => Task.FromResult(0L),
+ (mail, token) => Task.FromResult((0L, "")),
threshold: 0.90,
firstBatchDeadline: invalid
);
@@ -353,7 +353,7 @@ public async Task DequeueAsync_DeadlineExpiry_EmitsOneExpiryLineAndKeepsPerCandi
(mail, token) =>
{
fakeTime.Advance(TimeSpan.FromSeconds(1));
- return Task.FromResult(100L);
+ return Task.FromResult((100L, ""));
},
threshold: 0.90,
timeProvider: fakeTime,
@@ -393,7 +393,7 @@ public async Task DequeueAsync_CancelledDuringEmptyQueueWait_ThrowsOperationCanc
var fakeTime = new FakeTimeProvider();
object gate = CreateGate(
() => null,
- (mail, token) => Task.FromResult(950L),
+ (mail, token) => Task.FromResult((950L, "")),
threshold: 0.90,
timeProvider: fakeTime,
sourceActive: () => true,
@@ -439,7 +439,7 @@ public async Task DequeueAsync_CancelledDuringScoring_ThrowsOperationCanceled()
{
// The score completes, but was cancelled while in flight.
cts.Cancel();
- return Task.FromResult(950L);
+ return Task.FromResult((950L, ""));
},
threshold: 0.90,
timeProvider: fakeTime,
diff --git a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs
index aa38afca1..0d66b7477 100644
--- a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs
+++ b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -7,6 +7,7 @@
using Microsoft.Extensions.Time.Testing;
using Microsoft.Office.Interop.Outlook;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using QuickFiler.Interfaces;
namespace QuickFiler.Controllers.Tests
{
@@ -40,9 +41,13 @@ public async Task DequeueAsync_ProgressCallback_FiresOncePerScannedCandidateMono
() => source.Count == 0 ? null : source.Dequeue(),
(mail, token) =>
Task.FromResult(
- ReferenceEquals(mail, candidates[1]) || ReferenceEquals(mail, candidates[3])
- ? 950L
- : 100L
+ (
+ ReferenceEquals(mail, candidates[1])
+ || ReferenceEquals(mail, candidates[3])
+ ? 950L
+ : 100L,
+ ""
+ )
),
threshold: 0.90,
timeProvider: new FakeTimeProvider(),
@@ -89,7 +94,7 @@ public async Task DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodRetur
(mail, token) =>
{
fakeTime.Advance(TimeSpan.FromSeconds(1));
- return Task.FromResult(100L);
+ return Task.FromResult((100L, ""));
},
threshold: 0.90,
timeProvider: fakeTime,
@@ -129,7 +134,7 @@ public async Task DequeueAsync_ThrowingProgressCallback_PropagatesAndLeavesSourc
object gate = CreateGate(
() => source.Count == 0 ? null : source.Dequeue(),
- (mail, token) => Task.FromResult(950L),
+ (mail, token) => Task.FromResult((950L, "")),
threshold: 0.90,
timeProvider: new FakeTimeProvider(),
sourceActive: () => false,
@@ -148,5 +153,118 @@ public async Task DequeueAsync_ThrowingProgressCallback_PropagatesAndLeavesSourc
.HaveCount(5, "only the first candidate was taken before the sink threw");
source.Dequeue().Should().BeSameAs(candidates[1], "the remainder stays takeable");
}
+
+ ///
+ /// Issue #446. A deadline-bounded empty result must be distinguishable from genuine source
+ /// exhaustion, otherwise the caller closes the UI queue for the rest of the session while
+ /// the master queue still holds unscanned items. Driven by FakeTimeProvider: each
+ /// score consumes one second of a three-second budget and nothing qualifies, so the
+ /// deadline exit is the one taken.
+ ///
+ [TestMethod]
+ public async Task DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ var source = new Queue(
+ Enumerable
+ .Range(1, 10)
+ .Select(i => CreateMailItem($"reject-{i}", $"entry-reject-{i}"))
+ );
+ object gate = CreateGate(
+ () => source.Count == 0 ? null : source.Dequeue(),
+ (mail, token) =>
+ {
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ return Task.FromResult((100L, ""));
+ },
+ threshold: 0.90,
+ timeProvider: fakeTime,
+ sourceActive: () => true,
+ firstBatchDeadline: TimeSpan.FromSeconds(3)
+ );
+
+ // Act
+ QfcGateBatch batch = await DequeueBatchAsync(gate, 1, 0, CancellationToken.None);
+
+ // Assert
+ batch
+ .Stop.Should()
+ .Be(
+ QfcDequeueStop.DeadlineExpired,
+ "an empty batch caused by the first-batch deadline is not source exhaustion"
+ );
+ batch.Accepted.Should().BeEmpty("no candidate qualified before the deadline");
+ }
+
+ ///
+ /// Issue #446. The complementary exit: when the take delegate returns null and the producer
+ /// reports it is no longer loading, the source really is drained and the caller may close
+ /// the queue.
+ ///
+ [TestMethod]
+ public async Task DequeueAsync_SourceDrained_ReportsSourceExhaustedStop()
+ {
+ // Arrange
+ object gate = CreateGate(
+ () => null,
+ (mail, token) => Task.FromResult((950L, "")),
+ threshold: 0.90,
+ timeProvider: new FakeTimeProvider(),
+ sourceActive: () => false
+ );
+
+ // Act
+ QfcGateBatch batch = await DequeueBatchAsync(gate, 1, 0, CancellationToken.None);
+
+ // Assert
+ batch
+ .Stop.Should()
+ .Be(
+ QfcDequeueStop.SourceExhausted,
+ "a drained source with no active producer is genuine exhaustion"
+ );
+ batch.Accepted.Should().BeEmpty("there was nothing to take");
+ }
+
+ ///
+ /// Issue #446 and Scope 427-A. The gate already computes the top-ranked folder for every
+ /// candidate it scores. Discarding that folder for accepted candidates forces the consuming
+ /// UI layer to re-score the same item against the same classifier, so the accepted carrier
+ /// must expose the folder the gate scored it against.
+ ///
+ [TestMethod]
+ public async Task DequeueAsync_AcceptedCandidate_CarriesTopFolderInPreScoredResult()
+ {
+ // Arrange
+ const string ExpectedFolder = @"Inbox\Projects\Alpha";
+ var candidate = CreateMailItem("accepted", "entry-accepted");
+ var source = new Queue(new[] { candidate });
+
+ object gate = CreateGate(
+ () => source.Count == 0 ? null : source.Dequeue(),
+ (mail, token) => Task.FromResult((950L, ExpectedFolder)),
+ threshold: 0.90,
+ timeProvider: new FakeTimeProvider(),
+ sourceActive: () => false
+ );
+
+ // Act
+ QfcGateBatch batch = await DequeueBatchAsync(gate, 1, 0, CancellationToken.None);
+
+ // Assert
+ QfcPreScoredItem accepted = batch
+ .Accepted.Should()
+ .ContainSingle("the single high-scoring candidate qualifies")
+ .Which;
+ accepted.MailItem.Should().BeSameAs(candidate);
+ accepted
+ .PredeterminedFolder.Should()
+ .Be(
+ ExpectedFolder,
+ "the folder the score loader already returned must travel with the accepted "
+ + "candidate instead of being discarded and re-derived downstream"
+ );
+ }
}
}
diff --git a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs
index 08555d7ec..944ad08f3 100644
--- a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs
+++ b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs
@@ -25,13 +25,14 @@ private static MailItem CreateMailItem(string subject, string entryId)
private static object CreateGate(
Func tryTakeNext,
- Func> scoreLoader,
+ Func> scoreLoader,
double threshold,
TimeProvider timeProvider = null,
Action debugLog = null,
Func sourceActive = null,
TimeSpan? firstBatchDeadline = null,
- Action progressCallback = null
+ Action progressCallback = null,
+ Action onRejected = null
)
{
Type gateType = typeof(QfcDatamodel).Assembly.GetType(
@@ -39,119 +40,45 @@ private static object CreateGate(
);
gateType.Should().NotBeNull("the dequeue-layer confidence gate must exist");
- // Issue #424: the gate gained an optional first-batch deadline and an optional progress
- // callback. Prefer the widest constructor; fall back to the older shapes so this helper
- // keeps compiling against a pre-#424 gate.
- ConstructorInfo constructorWithProgress = gateType.GetConstructor(
+ // Issue #446: one exact lookup for the widest declared constructor, guarded so the
+ // helper fails CLOSED. The former four-step descending fallback chain failed OPEN:
+ // when the wider lookups missed it silently succeeded on the five-type shape and
+ // constructed a gate with sourceActive null, the default deadline and no progress
+ // callback, across every consuming test method in this class.
+ ConstructorInfo constructor = gateType.GetConstructor(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
types: new[]
{
typeof(Func),
- typeof(Func>),
+ typeof(Func>),
typeof(double),
typeof(TimeProvider),
typeof(Action),
typeof(Func),
typeof(TimeSpan?),
typeof(Action),
+ typeof(Action),
},
modifiers: null
);
- if (constructorWithProgress != null)
- {
- return constructorWithProgress.Invoke(
- new object[]
- {
- tryTakeNext,
- scoreLoader,
- threshold,
- timeProvider,
- debugLog,
- sourceActive,
- firstBatchDeadline,
- progressCallback,
- }
- );
- }
-
- ConstructorInfo constructorWithDeadline = gateType.GetConstructor(
- BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
- binder: null,
- types: new[]
- {
- typeof(Func),
- typeof(Func>),
- typeof(double),
- typeof(TimeProvider),
- typeof(Action),
- typeof(Func),
- typeof(TimeSpan?),
- },
- modifiers: null
- );
- if (constructorWithDeadline != null)
- {
- return constructorWithDeadline.Invoke(
- new object[]
- {
- tryTakeNext,
- scoreLoader,
- threshold,
- timeProvider,
- debugLog,
- sourceActive,
- firstBatchDeadline,
- }
- );
- }
-
- ConstructorInfo constructorWithSourceState = gateType.GetConstructor(
- BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
- binder: null,
- types: new[]
- {
- typeof(Func),
- typeof(Func>),
- typeof(double),
- typeof(TimeProvider),
- typeof(Action),
- typeof(Func),
- },
- modifiers: null
- );
- if (constructorWithSourceState != null)
- {
- return constructorWithSourceState.Invoke(
- new object[]
- {
- tryTakeNext,
- scoreLoader,
- threshold,
- timeProvider,
- debugLog,
- sourceActive,
- }
- );
- }
-
- ConstructorInfo constructor = gateType.GetConstructor(
- BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
- binder: null,
- types: new[]
- {
- typeof(Func),
- typeof(Func>),
- typeof(double),
- typeof(TimeProvider),
- typeof(Action),
- },
- modifiers: null
- );
- constructor.Should().NotBeNull("the gate must expose the planned testable seam");
+ constructor
+ .Should()
+ .NotBeNull("the gate must expose the nine-parameter testable constructor seam");
return constructor.Invoke(
- new object[] { tryTakeNext, scoreLoader, threshold, timeProvider, debugLog }
+ new object[]
+ {
+ tryTakeNext,
+ scoreLoader,
+ threshold,
+ timeProvider,
+ debugLog,
+ sourceActive,
+ firstBatchDeadline,
+ progressCallback,
+ onRejected,
+ }
);
}
@@ -163,7 +90,8 @@ private static object CreateGate(
Action debugLog = null,
Func sourceActive = null,
TimeSpan? firstBatchDeadline = null,
- Action progressCallback = null
+ Action progressCallback = null,
+ Action onRejected = null
)
{
return CreateGate(
@@ -171,14 +99,15 @@ private static object CreateGate(
(mail, token) =>
{
token.ThrowIfCancellationRequested();
- return Task.FromResult(scores[mail]);
+ return Task.FromResult((scores[mail], ""));
},
threshold,
timeProvider,
debugLog,
sourceActive,
firstBatchDeadline,
- progressCallback
+ progressCallback,
+ onRejected
);
}
@@ -188,6 +117,21 @@ private static async Task> DequeueAsync(
int timeOut,
CancellationToken token
)
+ {
+ // Issue #446: the gate now returns a QfcGateBatch. Project Accepted back to
+ // IList so the pre-existing gate tests keep their current shape; the
+ // stop-reason and folder-carrying assertions use DequeueBatchAsync instead.
+ QfcGateBatch batch = await DequeueBatchAsync(gate, quantity, timeOut, token)
+ .ConfigureAwait(false);
+ return batch.Accepted.Select(x => x.MailItem).ToList();
+ }
+
+ private static async Task DequeueBatchAsync(
+ object gate,
+ int quantity,
+ int timeOut,
+ CancellationToken token
+ )
{
MethodInfo method = gate.GetType()
.GetMethod(
@@ -200,8 +144,7 @@ CancellationToken token
method.Should().NotBeNull("the gate must expose the planned dequeue operation");
var task =
- (Task>)
- method.Invoke(gate, new object[] { quantity, timeOut, token });
+ (Task)method.Invoke(gate, new object[] { quantity, timeOut, token });
return await task.ConfigureAwait(false);
}
@@ -282,7 +225,7 @@ public async Task DequeueAsync_PropagatesCancellationBeforeTakingSourceItem()
{
object gate = CreateGate(
() => throw new AssertFailedException("source must not be read after cancellation"),
- (mail, token) => Task.FromResult(1000L),
+ (mail, token) => Task.FromResult((1000L, "")),
threshold: 0.90
);
using (var cts = new CancellationTokenSource())
@@ -321,7 +264,7 @@ public async Task DequeueAsync_WhenSourceInitiallyEmpty_WaitsWithTimeProviderBef
takeCount++;
return takeCount == 1 ? null : item;
},
- (mail, token) => Task.FromResult(950L),
+ (mail, token) => Task.FromResult((950L, "")),
threshold: 0.90,
timeProvider: fakeTime
);
@@ -347,7 +290,7 @@ public async Task DequeueAsync_SourceActiveAfterRepeatedEmptyReads_ContinuesPoll
takeCount++;
return takeCount < 3 ? null : item;
},
- (mail, token) => Task.FromResult(950L),
+ (mail, token) => Task.FromResult((950L, "")),
threshold: 0.90,
timeProvider: fakeTime,
sourceActive: () => takeCount < 3
@@ -392,6 +335,107 @@ public async Task DequeueAsync_SubsequentScreenNonEmptyAcceptedPrefixPastDeadlin
.Equal(Enumerable.Range(1, 8).Select(i => $"high-{i}"));
}
+ ///
+ /// Issue #426. A candidate the gate discards has already been removed from the source
+ /// queue and never reaches the accepted-path unhook, so the gate must report it exactly
+ /// once through the rejection sink. Asserting the invocation count is what makes this a
+ /// real gate: a test that only asserted the item was discarded would pass vacuously.
+ ///
+ [TestMethod]
+ public async Task DequeueAsync_BelowThresholdCandidate_InvokesOnRejectedOnce()
+ {
+ // Arrange
+ var item = CreateMailItem("reject", "entry-reject");
+ var rejected = new List();
+ object gate = CreateGate(
+ new Queue(new[] { item }),
+ new Dictionary { [item] = 899 },
+ onRejected: rejected.Add
+ );
+
+ // Act
+ IList result = await DequeueAsync(gate, 1, 0, CancellationToken.None);
+
+ // Assert
+ result.Should().BeEmpty("the drop-on-reject contract is unchanged");
+ rejected
+ .Should()
+ .ContainSingle("the gate must report each discarded candidate exactly once")
+ .Which.Should()
+ .BeSameAs(item);
+ }
+
+ ///
+ /// Issue #426. A failing move monitor must not abort the dequeue scan. Drives one
+ /// below-cutoff candidate whose rejection sink throws, followed by an above-cutoff
+ /// candidate, and asserts both that the sink was invoked exactly once and that the scan
+ /// went on to accept the second candidate. Asserting the invocation count is what makes
+ /// this a real gate; a test that only asserted the scan continued would pass vacuously
+ /// while no sink exists.
+ ///
+ [TestMethod]
+ public async Task DequeueAsync_OnRejectedThrows_ScanContinues()
+ {
+ // Arrange
+ var low = CreateMailItem("low", "entry-low");
+ var high = CreateMailItem("high", "entry-high");
+ var invocations = new List();
+ object gate = CreateGate(
+ new Queue(new[] { low, high }),
+ new Dictionary { [low] = 899, [high] = 950 },
+ onRejected: mail =>
+ {
+ invocations.Add(mail);
+ throw new InvalidOperationException("monitor unavailable");
+ }
+ );
+
+ // Act
+ IList result = await DequeueAsync(gate, 1, 0, CancellationToken.None);
+
+ // Assert
+ invocations
+ .Should()
+ .ContainSingle("the throwing sink must still be invoked once for the rejected item")
+ .Which.Should()
+ .BeSameAs(low);
+ result
+ .Should()
+ .ContainSingle("a sink failure must not abort the scan")
+ .Which.Should()
+ .BeSameAs(high);
+ }
+
+ ///
+ /// Issue #426 negative control (AC13). An accepted candidate is unhooked on the accepted
+ /// path by UnhookDequeuedNodes, so the rejection sink must not fire for it; a
+ /// second release would be a double unhook. Green in both the pre-fix and post-fix states
+ /// by construction, so it is not tagged expect-fail.
+ ///
+ [TestMethod]
+ public async Task DequeueAsync_AcceptedCandidate_DoesNotInvokeOnRejected()
+ {
+ // Arrange
+ var item = CreateMailItem("accept", "entry-accept");
+ var rejected = new List();
+ object gate = CreateGate(
+ new Queue(new[] { item }),
+ new Dictionary { [item] = 950 },
+ onRejected: rejected.Add
+ );
+
+ // Act
+ IList result = await DequeueAsync(gate, 1, 0, CancellationToken.None);
+
+ // Assert
+ result.Should().ContainSingle().Which.Should().BeSameAs(item);
+ rejected
+ .Should()
+ .BeEmpty(
+ "an accepted candidate is released on the accepted path, not the rejection path"
+ );
+ }
+
private static async Task> DequeuePastDeadlineQualifiersAsync(int quantity)
{
var qualifiers = Enumerable
@@ -410,7 +454,7 @@ private static async Task> DequeuePastDeadlineQualifiersAsync(in
(mail, token) =>
{
fakeTime.Advance(TimeSpan.FromSeconds(1));
- return Task.FromResult(qualifiers.Contains(mail) ? 950L : 100L);
+ return Task.FromResult((qualifiers.Contains(mail) ? 950L : 100L, ""));
},
threshold: 0.90,
timeProvider: fakeTime,
diff --git a/QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs b/QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs
index 836ca3999..b58e583eb 100644
--- a/QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs
+++ b/QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Office.Interop.Outlook;
+using QuickFiler.Interfaces;
namespace QuickFiler.Controllers
{
@@ -98,6 +99,43 @@ Action progress
return await DequeueDirectAsync(quantity);
}
+ ///
+ /// Issue #446. Outcome-bearing dequeue. In high-confidence mode the gate's own stop reason
+ /// and accepted carriers are propagated verbatim. In normal mode nothing is scored, so
+ /// is empty and a short batch is reported as
+ /// : the direct path takes whatever the master
+ /// queue holds after , so fewer items than requested means the
+ /// source could not supply them.
+ ///
+ public async Task DequeueNextItemGroupWithOutcomeAsync(
+ int quantity,
+ int timeOut,
+ TimeSpan firstBatchDeadline,
+ Action progress
+ )
+ {
+ _token.ThrowIfCancellationRequested();
+
+ if (_globals?.QfSettings?.HighConfidenceModeEnabled == true)
+ {
+ return await DequeueWithHighConfidenceGateWithOutcomeAsync(
+ quantity,
+ timeOut,
+ firstBatchDeadline,
+ progress
+ );
+ }
+
+ IList items = await DequeueDirectAsync(quantity);
+ return new QfcDequeueBatch(
+ items,
+ new List(),
+ (items?.Count ?? 0) < quantity
+ ? QfcDequeueStop.SourceExhausted
+ : QfcDequeueStop.QuantitySatisfied
+ );
+ }
+
private async Task> DequeueDirectAsync(int quantity)
{
if (_masterQueue.Count < quantity)
@@ -113,6 +151,28 @@ private async Task> DequeueWithHighConfidenceGateAsync(
TimeSpan? firstBatchDeadline = null,
Action progress = null
)
+ {
+ QfcDequeueBatch batch = await DequeueWithHighConfidenceGateWithOutcomeAsync(
+ quantity,
+ timeOut,
+ firstBatchDeadline,
+ progress
+ );
+ return batch.Items;
+ }
+
+ ///
+ /// Issue #446 and Scope 427-A. The high-confidence dequeue with the gate's outcome intact.
+ /// is taken from the same accepted set as
+ /// , after has run
+ /// over it, so the two collections describe one dequeue rather than two.
+ ///
+ private async Task DequeueWithHighConfidenceGateWithOutcomeAsync(
+ int quantity,
+ int timeOut,
+ TimeSpan? firstBatchDeadline = null,
+ Action progress = null
+ )
{
var gate = new QfcStreamingDequeueConfidenceGate(
() => _masterQueue.TryTakeFirst(),
@@ -122,11 +182,36 @@ private async Task> DequeueWithHighConfidenceGateAsync(
null,
() => _remainingLoadActive,
firstBatchDeadline,
- progress
+ progress,
+ onRejected: TryReleaseRejectedHook
);
- var nodes = (await gate.DequeueAsync(quantity, timeOut, _token)).ToList();
- return UnhookDequeuedNodes(nodes);
+ QfcGateBatch batch = await gate.DequeueAsync(quantity, timeOut, _token);
+ IList accepted = batch.Accepted;
+ var nodes = accepted.Select(x => x.MailItem).ToList();
+ return new QfcDequeueBatch(UnhookDequeuedNodes(nodes), accepted, batch.Stop);
+ }
+
+ ///
+ /// Issue #426. Releases the EmailMoveMonitor hook of a candidate the high-confidence
+ /// gate discarded. The rejected candidate is already out of the master queue and never
+ /// reaches , so without this its hook and its live COM
+ /// reference are retained for the session. Exactly one UnhookItem call per rejected
+ /// item preserves the one-marshal-hop-per-operation contract. A monitor failure is logged
+ /// and swallowed: the candidate is discarded either way and aborting the scan would strand
+ /// the rest of the batch.
+ ///
+ private void TryReleaseRejectedHook(MailItem item)
+ {
+ try
+ {
+ _moveMonitor.UnhookItem(item);
+ }
+ catch (System.Exception e)
+ {
+ logger.Error("Error unhooking rejected item from move monitor", e);
+ return;
+ }
}
public IList DequeueNextItemGroup(int quantity)
@@ -165,6 +250,32 @@ private IList UnhookDequeuedNodes(List nodes)
return nodes;
}
+ ///
+ /// Injectable factory for the master-queue admission scorer. Defaults to a fresh
+ /// so production behaviour is unchanged; tests assign a
+ /// factory returning a mock so can be driven
+ /// without a live Outlook session, which
+ /// .claude/rules/general-unit-test.md UT4 requires.
+ ///
+ internal Func ScoringServiceFactory { get; set; } =
+ () => new FolderScoringService();
+
+ private async Task<(long Score, string TopFolder)> ScoreRemainingQueueMailItemAsync(
+ MailItem mailItem,
+ CancellationToken cancel
+ )
+ {
+ var scoringService = ScoringServiceFactory();
+ var score = await scoringService
+ .ScoreAsync(mailItem, _globals, cancel)
+ .ConfigureAwait(false);
+ logger.Debug(
+ $"Probability debug [QfcDatamodel.ScoreRemainingQueueMailItemAsync (master-queue admission)] "
+ + $"Subject='{mailItem.Subject}' EntryID='{mailItem.EntryID}' Score={score.Score}"
+ );
+ return (score.Score, score.TopFolder);
+ }
+
internal async Task WaitForQueue(int quantity, CancellationToken token)
{
while (_remainingLoadActive && (_masterQueue?.Count < quantity))
diff --git a/QuickFiler/Controllers/QfcDatamodel.cs b/QuickFiler/Controllers/QfcDatamodel.cs
index 6e830e09b..48b338405 100644
--- a/QuickFiler/Controllers/QfcDatamodel.cs
+++ b/QuickFiler/Controllers/QfcDatamodel.cs
@@ -352,7 +352,7 @@ CancellationToken cancel
{
var admission = new QfcRemainingQueueAdmission(
_globals,
- ScoreRemainingQueueMailItemAsync,
+ async (m, t) => (await ScoreRemainingQueueMailItemAsync(m, t)).Score,
_masterQueue.AddLast,
_moveMonitor.HookItem,
x => _masterQueue.Remove(x)
@@ -360,22 +360,6 @@ CancellationToken cancel
return await admission.TryQueueAsync(mailItem, cancel).ConfigureAwait(false);
}
- private async Task ScoreRemainingQueueMailItemAsync(
- MailItem mailItem,
- CancellationToken cancel
- )
- {
- var scoringService = new FolderScoringService();
- var score = await scoringService
- .ScoreAsync(mailItem, _globals, cancel)
- .ConfigureAwait(false);
- logger.Debug(
- $"Probability debug [QfcDatamodel.ScoreRemainingQueueMailItemAsync (master-queue admission)] "
- + $"Subject='{mailItem.Subject}' EntryID='{mailItem.EntryID}' Score={score.Score}"
- );
- return score.Score;
- }
-
private bool LoadRemainingEmailsToQueue(BackgroundWorker bw, CancellationToken token)
{
if ((_frame is null) || (_frame.RowCount == 0))
diff --git a/QuickFiler/Controllers/QfcFormController.Actions.cs b/QuickFiler/Controllers/QfcFormController.Actions.cs
index a38604113..7d57f3965 100644
--- a/QuickFiler/Controllers/QfcFormController.Actions.cs
+++ b/QuickFiler/Controllers/QfcFormController.Actions.cs
@@ -201,6 +201,62 @@ public void MinimizeFormViewer()
);
}
+ ///
+ /// Issue #448. Clock seam for . Defaults to
+ /// so production behaviour is unchanged; tests
+ /// assign a FakeTimeProvider so the ten-second idle threshold can be driven without
+ /// a real wall-clock wait, which `.claude/rules/general-unit-test.md` requires.
+ ///
+ internal TimeProvider TimeProvider { get; set; } = TimeProvider.System;
+
+ ///
+ /// Issue #448. Start seam for the undo consumer. Defaults to Task.Run so production
+ /// behaviour is unchanged; tests assign body => body() to run the consumer inline
+ /// and observe its completion deterministically.
+ ///
+ internal Func, Task> UndoConsumerStarter { get; set; } = body => Task.Run(body);
+
+ private Func _undoItemProcessor;
+
+ ///
+ /// Issue #448. Per-item seam for the undo consumer's successful-take branch. Defaults to
+ /// , which holds that branch verbatim, so production
+ /// behaviour is byte-for-byte unchanged. The default is resolved lazily rather than in a
+ /// property initializer because an instance initializer cannot reference an instance method
+ /// (CS0236). Tests assign a fake so no live Outlook COM call and no WinForms dispatcher
+ /// call is made, which `.claude/rules/general-unit-test.md` UT4 prohibits in unit tests.
+ ///
+ internal Func UndoItemProcessor
+ {
+ get => _undoItemProcessor ??= ProcessUndoItemAsync;
+ set => _undoItemProcessor = value;
+ }
+
+ ///
+ /// The undo consumer's successful-take branch, extracted verbatim so it can be replaced
+ /// wholesale by a test double. Untrains the folder classifier on the moved item, moves the
+ /// mail back, and re-adds it to the on-screen group on the UI thread.
+ ///
+ private async Task ProcessUndoItemAsync(IMovedMailInfo item)
+ {
+ var helper = await MailItemHelper.FromMailItemAsync(
+ item.MailItem,
+ _globals,
+ default,
+ true
+ );
+ (await _globals.AF.Manager["Folder"]).UnTrain(
+ helper.FolderInfo.RelativePath,
+ helper.Tokens,
+ 1
+ );
+ var mail = item.UndoMove();
+ await UiThread.Dispatcher.InvokeAsync(
+ () => _groups.AddItemGroup(mail),
+ System.Windows.Threading.DispatcherPriority.ContextIdle
+ );
+ }
+
internal void UndoDialog()
{
if (_movedItems is null || _globals?.Ol?.App is null)
@@ -208,7 +264,7 @@ internal void UndoDialog()
return;
}
- _undoConsumerTask ??= Task.Run(UndoConsumer);
+ _undoConsumerTask ??= UndoConsumerStarter(UndoConsumer);
var olApp = _globals.Ol.App;
DialogResult repeatResponse = DialogResult.Yes;
var i = 0;
@@ -250,43 +306,45 @@ internal void UndoDialog()
_movedItems.Serialize();
}
+ ///
+ /// Issue #448. How long the undo consumer stays alive with nothing to take before it exits.
+ /// Preserves the previous ten-second threshold; the change is that it now measures idle time
+ /// rather than total session time.
+ ///
+ private static readonly TimeSpan UndoConsumerIdleTimeout = TimeSpan.FromSeconds(10);
+
internal async Task UndoConsumer()
{
- var sw = new Stopwatch();
- sw.Start();
- bool exit = false;
- while (!_undoQueue.IsCompleted || exit)
+ long start = TimeProvider.GetTimestamp();
+ try
{
- if (_undoQueue.TryTake(out var item))
- {
- var helper = await MailItemHelper.FromMailItemAsync(
- item.MailItem,
- _globals,
- default,
- true
- );
- (await _globals.AF.Manager["Folder"]).UnTrain(
- helper.FolderInfo.RelativePath,
- helper.Tokens,
- 1
- );
- var mail = item.UndoMove();
- await UiThread.Dispatcher.InvokeAsync(
- () => _groups.AddItemGroup(mail),
- System.Windows.Threading.DispatcherPriority.ContextIdle
- );
- }
- else if (sw.ElapsedMilliseconds > 10000)
+ while (!_undoQueue.IsCompleted)
{
- exit = true;
- }
- else
- {
- await Task.Delay(200);
+ if (_undoQueue.TryTake(out var item))
+ {
+ await UndoItemProcessor(item).ConfigureAwait(false);
+
+ // Reset on every successful take so the threshold measures idle time. The
+ // previous code started one stopwatch for the whole session, so a consumer
+ // busy for ten seconds exited while items were still arriving.
+ start = TimeProvider.GetTimestamp();
+ }
+ else if (TimeProvider.GetElapsedTime(start) > UndoConsumerIdleTimeout)
+ {
+ break;
+ }
+ else
+ {
+ await TimeProvider
+ .Delay(TimeSpan.FromMilliseconds(200))
+ .ConfigureAwait(false);
+ }
}
}
- if (exit)
+ finally
{
+ // Unconditional so a later UndoDialog() starts a fresh consumer even when this one
+ // exited by exception, which disposing _undoQueue mid-take can produce.
_undoConsumerTask = null;
}
}
diff --git a/QuickFiler/Controllers/QfcHomeController.Iteration.cs b/QuickFiler/Controllers/QfcHomeController.Iteration.cs
index a6564d194..ed34f111e 100644
--- a/QuickFiler/Controllers/QfcHomeController.Iteration.cs
+++ b/QuickFiler/Controllers/QfcHomeController.Iteration.cs
@@ -1,8 +1,9 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.Office.Interop.Outlook;
+using QuickFiler.Interfaces;
namespace QuickFiler.Controllers
{
@@ -18,10 +19,13 @@ public async Task IterateQueueAsync()
}
try
{
- var listObjects = await _datamodel.DequeueNextItemGroupAsync(
+ QfcDequeueBatch batch = await _datamodel.DequeueNextItemGroupWithOutcomeAsync(
_formController.ItemsPerIteration,
- 2000
+ 2000,
+ QfcStreamingDequeueConfidenceGate.DefaultFirstBatchDeadline,
+ null
);
+ IList listObjects = batch.Items;
if (listObjects.Count > 0)
{
//await UiThread.Dispatcher.InvokeAsync(async () => await QfcQueue.EnqueueAsync(listObjects, _formController.Groups));
@@ -29,8 +33,13 @@ await QfcQueue
.EnqueueAsync(listObjects, _formController.Groups)
.ConfigureAwait(false);
}
- else
+ else if (batch.Stop == QfcDequeueStop.SourceExhausted)
{
+ // Issue #446. Only genuine source exhaustion may close the queue:
+ // CompleteAddingAsync reaches BlockingCollection.CompleteAdding(), which is
+ // irreversible. An empty batch whose stop reason is DeadlineExpired or
+ // QuantitySatisfied leaves the queue open so a later iteration can drain the
+ // items the master queue still holds.
//logger.Debug($"{nameof(IterateQueueAsync)} completed");
await QfcQueue.CompleteAddingAsync(Token, 10000);
}
diff --git a/QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs b/QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs
index 1d27d0e1c..bd41ca2d1 100644
--- a/QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs
+++ b/QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs
@@ -3,9 +3,42 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Office.Interop.Outlook;
+using QuickFiler.Interfaces;
namespace QuickFiler.Controllers
{
+ ///
+ /// Issue #446 and Scope 427-A. The gate's own result: the accepted candidates with the folder
+ /// each was already scored against, the reason the scan stopped, and how many candidates were
+ /// scanned. Declared as a readonly struct with get-only properties because
+ /// net481 has no IsExternalInit and therefore no record,
+ /// record struct or init accessor.
+ ///
+ internal readonly struct QfcGateBatch
+ {
+ private readonly IList _accepted;
+
+ ///
+ /// Creates a gate result. A null accepted collection surfaces as an empty list so a
+ /// defaulted struct is inert rather than a null-reference trap.
+ ///
+ public QfcGateBatch(IList accepted, QfcDequeueStop stop, int scanned)
+ {
+ _accepted = accepted;
+ Stop = stop;
+ Scanned = scanned;
+ }
+
+ /// The accepted candidates, each carrying its predetermined folder.
+ public IList Accepted => _accepted ?? new List();
+
+ /// Why the scan stopped.
+ public QfcDequeueStop Stop { get; }
+
+ /// How many candidates were scored during the scan.
+ public int Scanned { get; }
+ }
+
internal sealed class QfcStreamingDequeueConfidenceGate
{
private static readonly log4net.ILog logger = log4net.LogManager.GetLogger(
@@ -22,17 +55,22 @@ internal sealed class QfcStreamingDequeueConfidenceGate
internal static readonly TimeSpan DefaultFirstBatchDeadline = TimeSpan.FromSeconds(12);
private readonly Func _tryTakeNext;
- private readonly Func> _scoreLoader;
+ private readonly Func<
+ MailItem,
+ CancellationToken,
+ Task<(long Score, string TopFolder)>
+ > _scoreLoader;
private readonly long _cutoff;
private readonly TimeProvider _timeProvider;
private readonly Action _debugLog;
private readonly Func _sourceActive;
private readonly TimeSpan _firstBatchDeadline;
private readonly Action _progressCallback;
+ private readonly Action _onRejected;
internal QfcStreamingDequeueConfidenceGate(
Func tryTakeNext,
- Func> scoreLoader,
+ Func> scoreLoader,
double threshold,
TimeProvider timeProvider = null,
Action debugLog = null
@@ -54,15 +92,24 @@ internal QfcStreamingDequeueConfidenceGate(
/// callback must not touch UI directly — callers route reports through ProgressTracker,
/// which marshals to the UI thread.
///
+ ///
+ /// Issue #426. Optional sink invoked once for every candidate the gate discards because its
+ /// score is below the cutoff. A rejected candidate has already been removed from the source
+ /// queue and never reaches the accepted-path unhook, so without this sink its
+ /// EmailMoveMonitor hook and its live COM reference are retained for the session.
+ /// disables the sink. The drop-on-reject contract is unchanged: the
+ /// candidate is still discarded and is still absent from the result.
+ ///
internal QfcStreamingDequeueConfidenceGate(
Func tryTakeNext,
- Func> scoreLoader,
+ Func> scoreLoader,
double threshold,
TimeProvider timeProvider,
Action debugLog,
Func sourceActive,
TimeSpan? firstBatchDeadline = null,
- Action progressCallback = null
+ Action progressCallback = null,
+ Action onRejected = null
)
{
_tryTakeNext = tryTakeNext ?? throw new ArgumentNullException(nameof(tryTakeNext));
@@ -72,6 +119,7 @@ internal QfcStreamingDequeueConfidenceGate(
_debugLog = debugLog;
_sourceActive = sourceActive;
_progressCallback = progressCallback;
+ _onRejected = onRejected;
TimeSpan deadline = firstBatchDeadline ?? DefaultFirstBatchDeadline;
if (deadline != Timeout.InfiniteTimeSpan && deadline <= TimeSpan.Zero)
@@ -86,7 +134,7 @@ internal QfcStreamingDequeueConfidenceGate(
_firstBatchDeadline = deadline;
}
- internal async Task> DequeueAsync(
+ internal async Task DequeueAsync(
int quantity,
int timeOut,
CancellationToken token
@@ -94,15 +142,15 @@ CancellationToken token
{
token.ThrowIfCancellationRequested();
- var accepted = new List();
+ var accepted = new List();
+ int scanned = 0;
if (quantity <= 0)
{
- return accepted;
+ return new QfcGateBatch(accepted, QfcDequeueStop.QuantitySatisfied, scanned);
}
bool deadlineEnabled = _firstBatchDeadline != Timeout.InfiniteTimeSpan;
long start = _timeProvider.GetTimestamp();
- int scanned = 0;
bool alreadyWaitedForEmptySource = false;
while (accepted.Count < quantity)
@@ -116,7 +164,7 @@ CancellationToken token
)
{
LogDeadlineExpiry(accepted.Count, scanned);
- return accepted;
+ return new QfcGateBatch(accepted, QfcDequeueStop.DeadlineExpired, scanned);
}
MailItem mailItem = _tryTakeNext();
@@ -125,7 +173,7 @@ CancellationToken token
bool sourceCanStillProduce = _sourceActive?.Invoke() == true;
if (timeOut <= 0 || (alreadyWaitedForEmptySource && !sourceCanStillProduce))
{
- return accepted;
+ return new QfcGateBatch(accepted, QfcDequeueStop.SourceExhausted, scanned);
}
alreadyWaitedForEmptySource = true;
@@ -136,14 +184,34 @@ await _timeProvider
}
alreadyWaitedForEmptySource = false;
- long score = await _scoreLoader(mailItem, token).ConfigureAwait(false);
+ (long score, string topFolder) = await _scoreLoader(mailItem, token)
+ .ConfigureAwait(false);
token.ThrowIfCancellationRequested();
scanned++;
LogScore(mailItem, score);
if (score >= _cutoff)
{
- accepted.Add(mailItem);
+ accepted.Add(new QfcPreScoredItem(mailItem, topFolder));
+ }
+ else
+ {
+ // Issue #426. The discarded candidate is already out of the source queue and
+ // never reaches the accepted-path unhook, so it is reported here. A monitor
+ // failure must not abort the scan, hence the catch: the candidate is still
+ // dropped either way, and aborting would strand the rest of the batch.
+ try
+ {
+ _onRejected?.Invoke(mailItem);
+ }
+ catch (System.Exception e)
+ {
+ logger.Error(
+ "Rejection sink threw [QfcStreamingDequeueConfidenceGate.DequeueAsync]; "
+ + "the candidate is still discarded and the scan continues.",
+ e
+ );
+ }
}
// Report after the accept decision so `accepted` reflects this candidate. Exceptions
@@ -151,7 +219,7 @@ await _timeProvider
_progressCallback?.Invoke(scanned, accepted.Count, quantity);
}
- return accepted;
+ return new QfcGateBatch(accepted, QfcDequeueStop.QuantitySatisfied, scanned);
}
private void LogDeadlineExpiry(int acceptedCount, int scannedCount)
diff --git a/QuickFiler/Interfaces/IQfcDatamodel.cs b/QuickFiler/Interfaces/IQfcDatamodel.cs
index be5b8bc47..216bbcf62 100644
--- a/QuickFiler/Interfaces/IQfcDatamodel.cs
+++ b/QuickFiler/Interfaces/IQfcDatamodel.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Office.Interop.Outlook;
+using QuickFiler.Controllers;
using UtilitiesCS;
using UtilitiesCS.ReusableTypeClasses.SerializableNew.Concurrent.Observable;
@@ -21,6 +22,64 @@ public enum SortOptionsEnum
ConversationUniqueOnly = 32,
}
+ ///
+ /// Issue #446. Why the dequeue stopped. A caller cannot otherwise distinguish a
+ /// deadline-bounded empty batch from genuine exhaustion of the mail source, and treating the
+ /// former as the latter irreversibly closes the UI queue for the rest of the session.
+ ///
+ public enum QfcDequeueStop
+ {
+ /// The requested quantity was assembled, or the request was degenerate.
+ QuantitySatisfied,
+
+ /// The mail source is drained and no producer is still loading.
+ SourceExhausted,
+
+ /// The first-batch deadline expired before any candidate qualified.
+ DeadlineExpired,
+ }
+
+ ///
+ /// Issue #446 and Scope 427-A. The dequeue result at the datamodel boundary: the batch, the
+ /// pre-scored carriers that survived the high-confidence gate, and the reason the dequeue
+ /// stopped. Declared as a readonly struct with get-only properties because
+ /// net481 has no IsExternalInit and therefore no record,
+ /// record struct or init accessor.
+ ///
+ public readonly struct QfcDequeueBatch
+ {
+ private readonly IList _items;
+ private readonly IList _preScored;
+
+ ///
+ /// Creates a dequeue result. Null collections are tolerated and surface as empty lists, so
+ /// a defaulted struct returned by an unconfigured loose Moq setup is inert rather than a
+ /// null-reference trap.
+ ///
+ public QfcDequeueBatch(
+ IList items,
+ IList preScored,
+ QfcDequeueStop stop
+ )
+ {
+ _items = items;
+ _preScored = preScored;
+ Stop = stop;
+ }
+
+ /// The dequeued mail items. Never null; empty when nothing was dequeued.
+ public IList Items => _items ?? new List();
+
+ ///
+ /// The pre-scored carriers for the accepted items, each pairing a mail item with the folder
+ /// the gate already computed for it. Never null; empty outside high-confidence mode.
+ ///
+ public IList PreScored => _preScored ?? new List