diff --git a/QuickFiler.Test/Controllers/QfcCollectionControllerTests.Part2.cs b/QuickFiler.Test/Controllers/QfcCollectionControllerTests.Part2.cs
new file mode 100644
index 000000000..1fb029017
--- /dev/null
+++ b/QuickFiler.Test/Controllers/QfcCollectionControllerTests.Part2.cs
@@ -0,0 +1,73 @@
+using System.Reflection;
+using FluentAssertions;
+using Microsoft.Office.Interop.Outlook;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using QuickFiler.Controllers;
+using UtilitiesCS;
+
+namespace QuickFiler.Controllers.Tests
+{
+ ///
+ /// Carrier-list carry tests for QfcCollectionController. Relocated here from
+ /// QfcCollectionControllerTests.cs, which stood at 499 lines with one line of headroom to
+ /// the 500-line cap, because the issue #678 widening of adds an
+ /// argument to the construction below and CSharpier then reflows the call across several lines.
+ /// No test is deleted or weakened by the move; the base part carries the only
+ /// [TestClass] attribute.
+ ///
+ public partial class QfcCollectionControllerTests
+ {
+ ///
+ /// [P4-T7] The carrier-list load path carries each survivor's predetermined folder onto the
+ /// resulting . The full carrier
+ /// LoadControlsAndHandlers_01Async / EncapsulateItemGroup body constructs a real
+ /// and dequeues a WinForms ItemViewer, which require live
+ /// COM/WinForms; the COM-free carry contract verified here is that the carrier value flows from
+ /// onto the item group's
+ /// . The item controller's consumption of that
+ /// value (preselecting the folder, not index 1) is verified in P5-T3.
+ /// Issue #678 extends the same COM-free carry contract to the folder search handler: the
+ /// carrier now publishes it and the item group now carries it alongside the folder.
+ ///
+ [TestMethod]
+ public void CarrierLoad_SetsPredeterminedFolderOnItemGroup()
+ {
+ // Arrange — the carrier the load path produces for a survivor.
+ var mail = new Mock(MockBehavior.Loose).Object;
+ var handler = new Mock().Object;
+ var carrier = new QfcPreScoredItem(mail, @"\\Archive\Projects\Active", handler);
+
+ // Act — replicate the group-level carry that EncapsulateItemGroup performs before any
+ // COM/WinForms call: new QfcItemGroup(mailItem) { PredeterminedFolder = ... }.
+ var group = new QfcItemGroup(carrier.MailItem)
+ {
+ PredeterminedFolder = carrier.PredeterminedFolder,
+ CarriedFolderHandler = carrier.FolderHandler,
+ };
+
+ // Assert — the predetermined folder is carried onto the group and the mail item matches.
+ typeof(QfcItemGroup)
+ .GetProperty(
+ nameof(QfcItemGroup.PredeterminedFolder),
+ BindingFlags.NonPublic | BindingFlags.Instance
+ )
+ .GetValue(group)
+ .Should()
+ .Be(@"\\Archive\Projects\Active");
+ group.MailItem.Should().BeSameAs(mail);
+
+ // Assert — issue #678: the already-initialised handler is carried onto the group too, so
+ // the item controller can adopt it instead of running a second scoring pass.
+ typeof(QfcItemGroup)
+ .GetProperty(
+ nameof(QfcItemGroup.CarriedFolderHandler),
+ BindingFlags.NonPublic | BindingFlags.Instance
+ )
+ .GetValue(group)
+ .Should()
+ .BeSameAs(handler);
+ carrier.FolderHandler.Should().BeSameAs(handler);
+ }
+ }
+}
diff --git a/QuickFiler.Test/Controllers/QfcCollectionControllerTests.cs b/QuickFiler.Test/Controllers/QfcCollectionControllerTests.cs
index cc9ab49cd..067a300c3 100644
--- a/QuickFiler.Test/Controllers/QfcCollectionControllerTests.cs
+++ b/QuickFiler.Test/Controllers/QfcCollectionControllerTests.cs
@@ -21,7 +21,7 @@ namespace QuickFiler.Controllers.Tests
/// constructor; all required private fields are then injected via reflection.
///
[TestClass]
- public class QfcCollectionControllerTests
+ public partial class QfcCollectionControllerTests
{
///
/// Creates an uninitialized QfcCollectionController with only the fields required
@@ -287,43 +287,8 @@ out var removed
removed.Should().Equal("noSuggestion");
}
- // ---- Carrier-list PredeterminedFolder carry (Issue #171) ----
-
- ///
- /// [P4-T7] The carrier-list load path carries each survivor's predetermined folder onto the
- /// resulting . The full carrier
- /// LoadControlsAndHandlers_01Async / EncapsulateItemGroup body constructs a real
- /// and dequeues a WinForms ItemViewer, which require live
- /// COM/WinForms; the COM-free carry contract verified here is that the carrier value flows from
- /// onto the item group's
- /// . The item controller's consumption of that
- /// value (preselecting the folder, not index 1) is verified in P5-T3.
- ///
- [TestMethod]
- public void CarrierLoad_SetsPredeterminedFolderOnItemGroup()
- {
- // Arrange — the carrier the load path produces for a survivor.
- var mail = new Mock(MockBehavior.Loose).Object;
- var carrier = new QfcPreScoredItem(mail, @"\\Archive\Projects\Active");
-
- // Act — replicate the group-level carry that EncapsulateItemGroup performs before any
- // COM/WinForms call: new QfcItemGroup(mailItem) { PredeterminedFolder = ... }.
- var group = new QfcItemGroup(carrier.MailItem)
- {
- PredeterminedFolder = carrier.PredeterminedFolder,
- };
-
- // Assert — the predetermined folder is carried onto the group and the mail item matches.
- typeof(QfcItemGroup)
- .GetProperty(
- nameof(QfcItemGroup.PredeterminedFolder),
- BindingFlags.NonPublic | BindingFlags.Instance
- )
- .GetValue(group)
- .Should()
- .Be(@"\\Archive\Projects\Active");
- group.MailItem.Should().BeSameAs(mail);
- }
+ // Carrier-list carry tests (Issue #171, extended for #678) live in the partial part
+ // QfcCollectionControllerTests.Part2.cs; see that file for the reason.
// ---- Navigation-key register/unregister on page swap (Issue #232) ----
diff --git a/QuickFiler.Test/Controllers/QfcDatamodelTests.cs b/QuickFiler.Test/Controllers/QfcDatamodelTests.cs
index a4b3beeef..a05f7ba14 100644
--- a/QuickFiler.Test/Controllers/QfcDatamodelTests.cs
+++ b/QuickFiler.Test/Controllers/QfcDatamodelTests.cs
@@ -322,6 +322,9 @@ public async Task WaitForQueue_WhenWorkerBusyAndQueueShort_AwaitsInjectedTwoHund
/// 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.
+ /// Issue #678 widened the seam to a third element, the initialised folder search handler;
+ /// this test additionally asserts that third element is forwarded rather than dropped, which
+ /// is the same discard defect one element to the right.
///
[TestMethod]
public async Task ScoreRemainingQueueMailItemAsync_ReturnsScoreAndTopFolder()
@@ -333,6 +336,7 @@ public async Task ScoreRemainingQueueMailItemAsync_ReturnsScoreAndTopFolder()
const long ExpectedScore = 875L;
const string ExpectedTopFolder = @"Inbox\Projects\Alpha";
+ IFolderSearchHandler expectedHandler = new Mock().Object;
var scoringService = new Mock(MockBehavior.Strict);
scoringService
@@ -343,16 +347,14 @@ public async Task ScoreRemainingQueueMailItemAsync_ReturnsScoreAndTopFolder()
It.IsAny()
)
)
- .ReturnsAsync((ExpectedScore, ExpectedTopFolder));
+ .ReturnsAsync((ExpectedScore, ExpectedTopFolder, expectedHandler));
SetPrivateField(model, "_globals", globals.Object);
model.ScoringServiceFactory = () => scoringService.Object;
// Act
- (long Score, string TopFolder) result = await InvokeScoreRemainingQueueMailItemAsync(
- model,
- mailItem
- );
+ (long Score, string TopFolder, IFolderSearchHandler Handler) result =
+ await InvokeScoreRemainingQueueMailItemAsync(model, mailItem);
// Assert
result
@@ -365,12 +367,20 @@ public async Task ScoreRemainingQueueMailItemAsync_ReturnsScoreAndTopFolder()
"the top-ranked folder the scorer already computed must reach the caller "
+ "instead of being discarded and re-derived downstream"
);
+ result
+ .Handler.Should()
+ .BeSameAs(
+ expectedHandler,
+ "issue #678: the folder search handler the scoring pass already initialised "
+ + "must reach the caller instead of being discarded and re-initialised"
+ );
}
- private static Task<(long Score, string TopFolder)> InvokeScoreRemainingQueueMailItemAsync(
- QfcDatamodel model,
- MailItem mailItem
- )
+ private static Task<(
+ long Score,
+ string TopFolder,
+ IFolderSearchHandler Handler
+ )> InvokeScoreRemainingQueueMailItemAsync(QfcDatamodel model, MailItem mailItem)
{
var method = typeof(QfcDatamodel).GetMethod(
"ScoreRemainingQueueMailItemAsync",
@@ -382,7 +392,7 @@ MailItem mailItem
"ScoreRemainingQueueMailItemAsync should exist on QfcDatamodel as a private "
+ "instance method"
);
- return (Task<(long Score, string TopFolder)>)
+ return (Task<(long Score, string TopFolder, IFolderSearchHandler Handler)>)
method.Invoke(model, new object[] { mailItem, CancellationToken.None });
}
diff --git a/QuickFiler.Test/Controllers/QfcFormControllerTests.Part2.cs b/QuickFiler.Test/Controllers/QfcFormControllerTests.Part2.cs
new file mode 100644
index 000000000..851010557
--- /dev/null
+++ b/QuickFiler.Test/Controllers/QfcFormControllerTests.Part2.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Office.Interop.Outlook;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using QuickFiler.Controllers;
+using QuickFiler.Interfaces;
+using UtilitiesCS;
+
+namespace QuickFiler.Controllers.Tests
+{
+ ///
+ /// High-confidence carrier-path tests for QfcFormController. Relocated here from
+ /// QfcFormControllerTests.cs, which stood at 827 lines and is already over the 500-line
+ /// cap, so it must not grow at all. The issue #678 widening of
+ /// adds an argument to the construction below and CSharpier then reflows the call, which would
+ /// have pushed that file further past its baseline count. No test is deleted or weakened by the
+ /// move; the base part carries the only [TestClass] attribute.
+ ///
+ public partial class QfcFormControllerTests
+ {
+ ///
+ /// [P4-T6] The carrier-list
+ /// path never invokes the post-UI removal pass
+ /// ( via
+ /// ). Because the carrier
+ /// overload constructs a real internally (no DI seam at
+ /// that point) which would require live WinForms/COM, this test exercises the overload via the
+ /// guard short-circuit (`_states` is null because Init() is not called) with an injected
+ /// collection-controller mock, and verifies no removal interaction occurs on the carrier path.
+ /// The positive carrier-overload behavior (LoadControlsAndHandlers_01Async and the carried
+ /// PredeterminedFolder) is verified at the collection-controller level in P4-T7 / P6-T2.
+ ///
+ [TestMethod]
+ public async Task LoadItemsAsync_PreScored_DoesNotInvokePostUiRemoval()
+ {
+ // Arrange — high-confidence mode on so the disabled-path branch is not the reason.
+ var settings = new Mock();
+ settings.SetupGet(s => s.HighConfidenceModeEnabled).Returns(true);
+ settings.SetupGet(s => s.HighConfidenceThreshold).Returns(0.9);
+ _mockGlobals.SetupGet(g => g.QfSettings).Returns(settings.Object);
+
+ _controller = CreateQfcFormController();
+ var mockGroups = new Mock(MockBehavior.Strict);
+ SetPrivateField(_controller, "_groups", mockGroups.Object);
+
+ // Issue #678: the carrier now publishes the already-initialised folder search handler
+ // as its third member, so this construction site populates it.
+ var preScored = new List
+ {
+ new QfcPreScoredItem(
+ new Mock().Object,
+ @"\\A\folder",
+ new Mock().Object
+ ),
+ };
+
+ // Act
+ Func act = () => _controller.LoadItemsAsync(preScored);
+
+ // Assert — no exception, and the post-UI removal pass is never invoked on the carrier path.
+ await act.Should().NotThrowAsync();
+ mockGroups.Verify(g => g.RemoveBelowThresholdAsync(It.IsAny()), Times.Never);
+ }
+ }
+}
diff --git a/QuickFiler.Test/Controllers/QfcFormControllerTests.cs b/QuickFiler.Test/Controllers/QfcFormControllerTests.cs
index 57e6f6cb4..223c894fa 100644
--- a/QuickFiler.Test/Controllers/QfcFormControllerTests.cs
+++ b/QuickFiler.Test/Controllers/QfcFormControllerTests.cs
@@ -17,7 +17,7 @@
namespace QuickFiler.Controllers.Tests
{
[TestClass]
- public class QfcFormControllerTests
+ public partial class QfcFormControllerTests
{
private Mock _mockGlobals;
private Mock _mockFormViewer;
@@ -784,43 +784,8 @@ public async Task ApplyHighConfidenceFilterAsync_WhenModeDisabled_NeverRemoves()
#region High-confidence pre-filter carrier path (Issue #171)
- ///
- /// [P4-T6] The carrier-list
- /// path never invokes the post-UI removal pass
- /// ( via
- /// ). Because the carrier
- /// overload constructs a real internally (no DI seam at
- /// that point) which would require live WinForms/COM, this test exercises the overload via the
- /// guard short-circuit (`_states` is null because Init() is not called) with an injected
- /// collection-controller mock, and verifies no removal interaction occurs on the carrier path.
- /// The positive carrier-overload behavior (LoadControlsAndHandlers_01Async and the carried
- /// PredeterminedFolder) is verified at the collection-controller level in P4-T7 / P6-T2.
- ///
- [TestMethod]
- public async Task LoadItemsAsync_PreScored_DoesNotInvokePostUiRemoval()
- {
- // Arrange — high-confidence mode on so the disabled-path branch is not the reason.
- var settings = new Mock();
- settings.SetupGet(s => s.HighConfidenceModeEnabled).Returns(true);
- settings.SetupGet(s => s.HighConfidenceThreshold).Returns(0.9);
- _mockGlobals.SetupGet(g => g.QfSettings).Returns(settings.Object);
-
- _controller = CreateQfcFormController();
- var mockGroups = new Mock(MockBehavior.Strict);
- SetPrivateField(_controller, "_groups", mockGroups.Object);
-
- var preScored = new List
- {
- new QfcPreScoredItem(new Mock().Object, @"\\A\folder"),
- };
-
- // Act
- Func act = () => _controller.LoadItemsAsync(preScored);
-
- // Assert — no exception, and the post-UI removal pass is never invoked on the carrier path.
- await act.Should().NotThrowAsync();
- mockGroups.Verify(g => g.RemoveBelowThresholdAsync(It.IsAny()), Times.Never);
- }
+ // LoadItemsAsync_PreScored_DoesNotInvokePostUiRemoval lives in the partial part
+ // QfcFormControllerTests.Part2.cs; see that file for the reason.
#endregion High-confidence pre-filter carrier path (Issue #171)
}
diff --git a/QuickFiler.Test/Controllers/QfcHighConfidencePreFilterTests.cs b/QuickFiler.Test/Controllers/QfcHighConfidencePreFilterTests.cs
index 6c657a9d7..b08de86e4 100644
--- a/QuickFiler.Test/Controllers/QfcHighConfidencePreFilterTests.cs
+++ b/QuickFiler.Test/Controllers/QfcHighConfidencePreFilterTests.cs
@@ -81,11 +81,15 @@ private static Mock BuildScoringMock(
(MailItem item, IApplicationGlobals g, CancellationToken t) =>
{
t.ThrowIfCancellationRequested();
+ // Issue #678 widened the seam's third element to the initialised handler.
+ // This scripted double publishes none, which the carrier tolerates.
if (script.TryGetValue(item, out var entry))
{
- return Task.FromResult((entry.score, entry.topFolder));
+ return Task.FromResult(
+ (entry.score, entry.topFolder, (IFolderSearchHandler)null)
+ );
}
- return Task.FromResult((0L, string.Empty));
+ return Task.FromResult((0L, string.Empty, (IFolderSearchHandler)null));
}
);
return mock;
diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerIssue218Tests.cs b/QuickFiler.Test/Controllers/QfcHomeControllerIssue218Tests.cs
index 088eebe76..c76e65b70 100644
--- a/QuickFiler.Test/Controllers/QfcHomeControllerIssue218Tests.cs
+++ b/QuickFiler.Test/Controllers/QfcHomeControllerIssue218Tests.cs
@@ -97,6 +97,9 @@ ProgressTracker progress
)
)
.ReturnsAsync(new List());
+ // Issue #678: high-confidence RunAsync moved from the plain dequeue to the
+ // outcome-returning member, which is the only one that surfaces the carriers. Both are
+ // configured so the disabled-mode assertions in this class stay meaningful.
mockDataModel
.Setup(x =>
x.DequeueNextItemGroupAsync(
@@ -107,6 +110,22 @@ ProgressTracker progress
)
)
.ReturnsAsync(new List());
+ mockDataModel
+ .Setup(x =>
+ x.DequeueNextItemGroupWithOutcomeAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )
+ )
+ .ReturnsAsync(
+ new QfcDequeueBatch(
+ new List(),
+ new List(),
+ QfcDequeueStop.QuantitySatisfied
+ )
+ );
mockDataModel.Setup(x => x.Complete).Returns(true);
_controller.DataModel = mockDataModel.Object;
@@ -158,26 +177,28 @@ public async Task RunAsync_HighConfidenceEnabled_DoesNotPreFilterInitialGuiBatch
.Should()
.BeFalse("remaining-queue admission now owns high-confidence filtering");
mockFormController.Verify(
- m => m.LoadItemsAsync(It.IsAny>()),
+ m => m.LoadItemsAsync(It.IsAny>()),
Times.Once,
- "the initial GUI batch must use the plain MailItem load path"
+ "issue #678: the initial GUI batch now uses the carrier load path, so the folder "
+ + "handler the gate already initialised reaches the item controller"
);
Mock.Get(_controller.DataModel)
.Verify(
m =>
- m.DequeueNextItemGroupAsync(
+ m.DequeueNextItemGroupWithOutcomeAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny>()
),
Times.Once,
- "the first displayed page must come from the dequeue-layer gate"
+ "the first displayed page must come from the dequeue-layer gate, now through "
+ + "the outcome-returning member that surfaces the carriers"
);
mockFormController.Verify(
- m => m.LoadItemsAsync(It.IsAny>()),
+ m => m.LoadItemsAsync(It.IsAny>()),
Times.Never,
- "RunAsync must not use the carrier-list overload for the initial batch"
+ "issue #678: the plain MailItem overload is no longer used in enabled mode"
);
}
@@ -201,16 +222,23 @@ public async Task RunAsync_HighConfidence_LoadsInitialBatchWithoutPreFilter()
)
)
.ReturnsAsync(new List());
+ // Issue #678: enabled-mode RunAsync now reads the outcome-returning dequeue member.
mockDataModel
.Setup(x =>
- x.DequeueNextItemGroupAsync(
+ x.DequeueNextItemGroupWithOutcomeAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny>()
)
)
- .ReturnsAsync(new List());
+ .ReturnsAsync(
+ new QfcDequeueBatch(
+ new List(),
+ new List(),
+ QfcDequeueStop.QuantitySatisfied
+ )
+ );
mockDataModel.Setup(x => x.Complete).Returns(true);
_controller.DataModel = mockDataModel.Object;
@@ -218,7 +246,7 @@ public async Task RunAsync_HighConfidence_LoadsInitialBatchWithoutPreFilter()
var mockFormController = new Mock();
mockFormController
- .Setup(x => x.LoadItemsAsync(It.IsAny>()))
+ .Setup(x => x.LoadItemsAsync(It.IsAny>()))
.Returns(Task.CompletedTask)
.Callback(() => sequence.Add("LoadItemsAsync"));
SetPrivateField(_controller, "_formController", mockFormController.Object);
@@ -244,7 +272,7 @@ public async Task RunAsync_HighConfidence_LoadsInitialBatchWithoutPreFilter()
sequence.Should().Equal("LoadItemsAsync");
mockDataModel.Verify(
m =>
- m.DequeueNextItemGroupAsync(
+ m.DequeueNextItemGroupWithOutcomeAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
@@ -253,8 +281,9 @@ public async Task RunAsync_HighConfidence_LoadsInitialBatchWithoutPreFilter()
Times.Once
);
mockFormController.Verify(
- m => m.LoadItemsAsync(It.IsAny>()),
- Times.Never
+ m => m.LoadItemsAsync(It.IsAny>()),
+ Times.Never,
+ "issue #678: enabled mode loads through the carrier overload only"
);
}
}
diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.Part2.cs b/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.Part2.cs
new file mode 100644
index 000000000..cd15705f6
--- /dev/null
+++ b/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.Part2.cs
@@ -0,0 +1,101 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Office.Interop.Outlook;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using QuickFiler.Interfaces;
+using UtilitiesCS;
+
+namespace QuickFiler.Controllers.Tests
+{
+ ///
+ /// Queue-iteration tests that constrain the arguments IterateQueueAsync hands to
+ /// IQfcQueue.EnqueueAsync, including the issue #678 leg-B carrier forwarding. Relocated
+ /// here from QfcHomeControllerIterationTests.cs, which stood at 497 lines with three
+ /// lines of headroom to the 500-line cap; widening the enqueue setup and both verifications for
+ /// the new third parameter, and adding the carrier-forwarding test, would have breached it.
+ /// No test is deleted or weakened by the move; the base part carries the only [TestClass]
+ /// attribute and the shared ArrangeIterate / VerifyCompleteAdding helpers.
+ ///
+ public partial class QfcHomeControllerIterationTests
+ {
+ [TestMethod]
+ public async Task IterateQueueAsync_WhenDequeueReturnsFullQualifiedPage_EnqueuesAllItems()
+ {
+ var mailItems = Enumerable
+ .Range(0, 8)
+ .Select(_ => new Mock().Object)
+ .ToList();
+ var (_, mockQfcQueue, _, mockQfcCollectionController) = ArrangeIterate(
+ q => q == 8,
+ t => t == 2000,
+ dequeued: mailItems
+ );
+
+ await _controller.IterateQueueAsync();
+
+ mockQfcQueue.Verify(
+ m =>
+ m.EnqueueAsync(
+ It.Is>(items => items.SequenceEqual(mailItems)),
+ mockQfcCollectionController.Object,
+ It.IsAny>()
+ ),
+ Times.Once
+ );
+ VerifyCompleteAdding(mockQfcQueue, Times.Never);
+ }
+
+ ///
+ /// AC6 (issue #678), leg B. Every page after the first is built by
+ /// IterateQueueAsync handing the dequeued batch to IQfcQueue.EnqueueAsync.
+ /// Before this change only batch.Items was forwarded, so the carriers on
+ /// batch.PreScored — and with them the folder search handler the dequeue-time gate
+ /// had already initialised — were dropped at that hop and every displayed row re-scored its
+ /// own item. This test pins that the carrier list reaches the queue intact: same count, and
+ /// the same handler instance associated with the same mail item.
+ ///
+ [TestMethod]
+ public async Task IterateQueueAsync_WhenBatchCarriesPreScoredItems_ForwardsCarriersToEnqueue()
+ {
+ // Arrange — a one-item batch whose carrier publishes a distinguishable handler.
+ MailItem mailItem = new Mock().Object;
+ IFolderSearchHandler carriedHandler = new Mock().Object;
+ IList items = new List { mailItem };
+ IList carriers = new List
+ {
+ new QfcPreScoredItem(mailItem, @"\\Archive\Projects\Active", carriedHandler),
+ };
+
+ var (_, mockQfcQueue, _, mockQfcCollectionController) = ArrangeIterate(
+ dequeued: items,
+ outcome: () =>
+ Task.FromResult(
+ new QfcDequeueBatch(items, carriers, QfcDequeueStop.QuantitySatisfied)
+ )
+ );
+
+ // Act
+ await _controller.IterateQueueAsync();
+
+ // Assert — the carriers reach EnqueueAsync as its third argument, carrying the handler.
+ mockQfcQueue.Verify(
+ m =>
+ m.EnqueueAsync(
+ It.IsAny>(),
+ mockQfcCollectionController.Object,
+ It.Is>(forwarded =>
+ forwarded != null
+ && forwarded.Count == 1
+ && ReferenceEquals(forwarded[0].MailItem, mailItem)
+ && ReferenceEquals(forwarded[0].FolderHandler, carriedHandler)
+ )
+ ),
+ Times.Once,
+ "leg B must forward batch.PreScored so pages after the first arrive with the "
+ + "already-initialised folder handler instead of re-scoring every row"
+ );
+ }
+ }
+}
diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs b/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs
index 5d532f645..e32ed1bc4 100644
--- a/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs
+++ b/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs
@@ -23,7 +23,7 @@
namespace QuickFiler.Controllers.Tests
{
[TestClass]
- public class QfcHomeControllerIterationTests
+ public partial class QfcHomeControllerIterationTests
{
private MockRepository _mockRepository;
private Mock _mockApplicationGlobals;
@@ -132,7 +132,8 @@ Mock Groups
.Setup(m =>
m.EnqueueAsync(
It.IsAny>(),
- It.IsAny()
+ It.IsAny(),
+ It.IsAny>()
)
)
.Returns(Task.CompletedTask);
@@ -174,7 +175,8 @@ private static void VerifyEnqueue(Mock queue, Func times) =>
m =>
m.EnqueueAsync(
It.IsAny>(),
- It.IsAny()
+ It.IsAny(),
+ It.IsAny>()
),
times
);
@@ -262,31 +264,9 @@ public async Task IterateQueueAsync_Queue2()
VerifyEnqueue(mockQfcQueue, Times.Once);
}
- [TestMethod]
- public async Task IterateQueueAsync_WhenDequeueReturnsFullQualifiedPage_EnqueuesAllItems()
- {
- var mailItems = Enumerable
- .Range(0, 8)
- .Select(_ => new Mock().Object)
- .ToList();
- var (_, mockQfcQueue, _, mockQfcCollectionController) = ArrangeIterate(
- q => q == 8,
- t => t == 2000,
- dequeued: mailItems
- );
-
- await _controller.IterateQueueAsync();
-
- mockQfcQueue.Verify(
- m =>
- m.EnqueueAsync(
- It.Is>(items => items.SequenceEqual(mailItems)),
- mockQfcCollectionController.Object
- ),
- Times.Once
- );
- VerifyCompleteAdding(mockQfcQueue, Times.Never);
- }
+ // IterateQueueAsync_WhenDequeueReturnsFullQualifiedPage_EnqueuesAllItems and the issue #678
+ // carrier-forwarding test live in the partial part QfcHomeControllerIterationTests.Part2.cs;
+ // see that file for the reason.
[TestMethod]
public void Iterate_ExecutesCorrectly()
diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part2.cs b/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part2.cs
new file mode 100644
index 000000000..008267c84
--- /dev/null
+++ b/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part2.cs
@@ -0,0 +1,241 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Office.Interop.Outlook;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using QuickFiler.Interfaces;
+using UtilitiesCS;
+
+namespace QuickFiler.Controllers.Tests
+{
+ ///
+ /// Issue #424 scan-progress and empty-batch tests for high-confidence
+ /// QfcHomeController.RunAsync. Relocated here from
+ /// QfcHomeControllerRunAsyncHighConfidenceTests.cs: the issue #678 rewrite of the
+ /// enabled-mode dequeue setups onto the outcome-returning member took that file from 473
+ /// lines to 544, past the 500-line limit. Both tests moved with their bodies otherwise
+ /// unchanged. This is a further part of the same partial class, which already carries its
+ /// [TestClass] attribute on the base file QfcHomeControllerRunAsyncTests.cs.
+ ///
+ public partial class QfcHomeControllerRunAsyncTests
+ {
+ ///
+ /// Issue #424 AC 6: the progress sink RunAsync hands to the dequeue overload maps gate
+ /// progress into the controller's 0-30 band. Every report the tracker receives between the
+ /// "Initializing Email Queue" and "Initializing Qfc Items" reports must lie within [0, 30]
+ /// and the sequence must be monotonically non-decreasing.
+ ///
+ [TestMethod]
+ public async Task RunAsync_HighConfidenceScanProgress_MapsReportsIntoTheZeroToThirtyBand()
+ {
+ // Arrange
+ var tokenSource = new CancellationTokenSource();
+ _mockProgressTracker = SetupMockProgressTracker(tokenSource);
+ ProgressTracker progress = _mockProgressTracker.Object;
+ const int itemsPerIteration = 4;
+
+ var reports = new List<(double Value, string Label)>();
+ _mockProgressTracker
+ .Setup(x => x.Report(It.IsAny(), It.IsAny()))
+ .Callback((value, label) => reports.Add((value, label)));
+
+ SetupQfSettings(highConfidenceEnabled: true, threshold: 0.90);
+
+ var mockDataModel = new Mock();
+ mockDataModel
+ .Setup(x =>
+ x.InitEmailQueueAsync(
+ 0,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync(new List());
+ // The mock captures the sink and drives it with a scripted scan before returning.
+ // Issue #678: the enabled-mode call site moved to the outcome-returning member, so the
+ // sink is captured from that member instead. The scripted scan and the four argument
+ // constraints are unchanged, so the 0-30 band assertion still measures what issue #424
+ // wrote it to measure.
+ mockDataModel
+ .Setup(x =>
+ x.DequeueNextItemGroupWithOutcomeAsync(
+ itemsPerIteration,
+ 200,
+ It.IsAny(),
+ It.IsAny>()
+ )
+ )
+ .Returns(
+ (
+ int quantity,
+ int timeOut,
+ TimeSpan deadline,
+ System.Action sink
+ ) =>
+ {
+ sink(1, 0, quantity);
+ sink(2, 1, quantity);
+ sink(3, 1, quantity);
+ sink(4, 2, quantity);
+ sink(5, 4, quantity);
+ return Task.FromResult(
+ new QfcDequeueBatch(
+ new List(),
+ new List(),
+ QfcDequeueStop.QuantitySatisfied
+ )
+ );
+ }
+ );
+ mockDataModel.Setup(x => x.Complete).Returns(true);
+ _controller.DataModel = mockDataModel.Object;
+
+ var mockFormController = new Mock();
+ mockFormController.SetupGet(x => x.ItemsPerIteration).Returns(itemsPerIteration);
+ // Issue #678: enabled mode loads through the carrier overload.
+ mockFormController
+ .Setup(x => x.LoadItemsAsync(It.IsAny>()))
+ .Returns(Task.CompletedTask);
+ SetPrivateField(_controller, "_formController", mockFormController.Object);
+
+ var mockFormViewer = new Mock();
+ mockFormViewer.SetupGet(x => x.Worker).Returns(new BackgroundWorker());
+ SetPrivateField(_controller, "_formViewer", mockFormViewer.Object);
+
+ // Act
+ await _controller.RunAsync(progress);
+
+ // Assert — isolate the reports emitted between the two label reports.
+ int start = reports.FindIndex(r => r.Label == "Initializing Email Queue");
+ int end = reports.FindIndex(r => r.Label == "Initializing Qfc Items");
+ start.Should().BeGreaterThanOrEqualTo(0, "RunAsync opens with the queue-init report");
+ end.Should().BeGreaterThan(start, "the Qfc-items report closes the scanning window");
+
+ List<(double Value, string Label)> scanReports = reports
+ .Skip(start + 1)
+ .Take(end - start - 1)
+ .ToList();
+
+ scanReports.Should().HaveCount(5, "one mapped report per scripted gate signal");
+ scanReports
+ .Should()
+ .OnlyContain(r => r.Value >= 0 && r.Value <= 30, "reports stay inside the band");
+ scanReports
+ .Should()
+ .OnlyContain(r => r.Label.StartsWith("Scanning for high-confidence items"));
+ for (int i = 1; i < scanReports.Count; i++)
+ {
+ scanReports[i]
+ .Value.Should()
+ .BeGreaterThanOrEqualTo(
+ scanReports[i - 1].Value,
+ "mapped progress must be monotonically non-decreasing"
+ );
+ }
+
+ reports[start].Value.Should().Be(0);
+ reports[end].Value.Should().Be(30);
+ }
+
+ ///
+ /// Issue #424 AC 2: when the deadline expires with nothing accepted, the empty batch still
+ /// reaches LoadItemsAsync (an empty list is not short-circuited by the null-guard at
+ /// QfcFormController.Actions.cs:68-79) and background iteration is still initiated.
+ ///
+ [TestMethod]
+ public async Task RunAsync_HighConfidenceEmptyBatch_StillLoadsItemsAndStartsIteration()
+ {
+ // Arrange
+ var tokenSource = new CancellationTokenSource();
+ _mockProgressTracker = SetupMockProgressTracker(tokenSource);
+ ProgressTracker progress = _mockProgressTracker.Object;
+ const int itemsPerIteration = 6;
+ var sinkInvoked = false;
+
+ SetupQfSettings(highConfidenceEnabled: true, threshold: 0.90);
+
+ var mockDataModel = new Mock();
+ mockDataModel
+ .Setup(x =>
+ x.InitEmailQueueAsync(
+ 0,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync(new List());
+ // Issue #678: the enabled-mode call site moved to the outcome-returning member.
+ mockDataModel
+ .Setup(x =>
+ x.DequeueNextItemGroupWithOutcomeAsync(
+ itemsPerIteration,
+ 200,
+ It.IsAny(),
+ It.IsAny>()
+ )
+ )
+ .Returns(
+ (
+ int quantity,
+ int timeOut,
+ TimeSpan deadline,
+ System.Action sink
+ ) =>
+ {
+ sink(9, 0, quantity);
+ sinkInvoked = true;
+ return Task.FromResult(
+ new QfcDequeueBatch(
+ new List(),
+ new List(),
+ QfcDequeueStop.DeadlineExpired
+ )
+ );
+ }
+ );
+ mockDataModel.Setup(x => x.Complete).Returns(true);
+ _controller.DataModel = mockDataModel.Object;
+
+ var mockFormController = new Mock();
+ mockFormController.SetupGet(x => x.ItemsPerIteration).Returns(itemsPerIteration);
+ // Issue #678: enabled mode loads through the carrier overload.
+ mockFormController
+ .Setup(x => x.LoadItemsAsync(It.IsAny>()))
+ .Returns(Task.CompletedTask);
+ SetPrivateField(_controller, "_formController", mockFormController.Object);
+
+ var mockFormViewer = new Mock();
+ mockFormViewer.SetupGet(x => x.Worker).Returns(new BackgroundWorker());
+ SetPrivateField(_controller, "_formViewer", mockFormViewer.Object);
+
+ // Act
+ await _controller.RunAsync(progress);
+
+ // Assert
+ sinkInvoked
+ .Should()
+ .BeTrue("the gate reports scan progress even when nothing qualifies");
+ mockFormController.Verify(
+ m =>
+ m.LoadItemsAsync(
+ It.Is>(carriers => carriers.Count == 0)
+ ),
+ Times.Once,
+ "an empty first batch must still reach the form path, not be short-circuited; the "
+ + "carrier overload's guard is null-not-empty, exactly as the plain one's is"
+ );
+ mockDataModel.Verify(
+ m => m.Complete,
+ Times.AtLeastOnce,
+ "background iteration must still be initiated after the empty first batch"
+ );
+ }
+ }
+}
diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part3.cs b/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part3.cs
new file mode 100644
index 000000000..5afc76e85
--- /dev/null
+++ b/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part3.cs
@@ -0,0 +1,247 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Reflection;
+using System.Runtime.Serialization;
+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;
+using QuickFiler.Interfaces;
+using UtilitiesCS;
+using UtilitiesCS.ReusableTypeClasses;
+
+namespace QuickFiler.Controllers.Tests
+{
+ // Issue #678, remediation cycle 1, item R1. This part carries the leg-A item-set invariant
+ // test. It declares no [TestClass] attribute of its own: the attribute on the base part
+ // (QfcHomeControllerRunAsyncTests.cs) covers the whole partial class, and a second one would
+ // be a duplicate-attribute error.
+ public partial class QfcHomeControllerRunAsyncTests
+ {
+ ///
+ /// Builds a loose mail-item mock whose EntryID is the supplied value. Loose rather
+ /// than strict because the production scoring and logging paths also read
+ /// Subject, which a strict mock would reject; no live Outlook COM is touched.
+ ///
+ private static MailItem MailItemWithEntryId(string entryId)
+ {
+ var mail = new Mock(MockBehavior.Loose);
+ mail.SetupGet(x => x.EntryID).Returns(entryId);
+ return mail.Object;
+ }
+
+ ///
+ /// Issue #678, R1. The set of mail items displayed on leg A must be exactly the set that
+ /// survived UnhookDequeuedNodes. No item whose UnhookItem call failed may be
+ /// displayed, and no item that TryUnhookOrReplace pulled out of the master queue may
+ /// go undisplayed.
+ ///
+ /// The test has two stages in one method so the divergence it asserts against is produced
+ /// by the real TryUnhookOrReplace throw branch rather than hand-built. Stage one
+ /// drives QfcDatamodel.DequeueNextItemGroupWithOutcomeAsync down that branch and
+ /// asserts the resulting batch genuinely diverges: Items holds only the substitute
+ /// and PreScored holds only the failed item. Stage two feeds that same batch through
+ /// QfcHomeController.RunAsync and asserts the carrier list reaching
+ /// LoadItemsAsync — the boundary that
+ /// QfcCollectionController.LoadControlsAndHandlers_01Async turns into rendered rows —
+ /// names the substitute and not the failed item.
+ ///
+ [TestMethod]
+ public async Task RunAsync_HighConfidenceUnhookReplaced_LoadsPostUnhookItemSetAtLegABoundary()
+ {
+ // ---------------------------------------------------------------------------------
+ // Stage one — arrange: produce a genuinely divergent batch from the real datamodel.
+ // ---------------------------------------------------------------------------------
+ var model = (QfcDatamodel)
+ FormatterServices.GetUninitializedObject(typeof(QfcDatamodel));
+
+ // FormatterServices.GetUninitializedObject runs no field initialiser, so TimeProvider
+ // is null and the gate's GetTimestamp call would throw. A FakeTimeProvider is the
+ // deterministic seam .claude/rules/general-unit-test.md requires; the clock is never
+ // advanced here because the quantity-satisfied exit is reached on the first iteration
+ // and needs no simulated time to elapse.
+ model.TimeProvider = new FakeTimeProvider();
+
+ MailItem failedItem = MailItemWithEntryId("entry-failed");
+ MailItem substituteItem = MailItemWithEntryId("entry-substitute");
+
+ var masterQueue = new LockingLinkedList();
+ masterQueue.AddLast(failedItem);
+ masterQueue.AddLast(substituteItem);
+
+ var settings = new Mock(MockBehavior.Strict);
+ settings.SetupGet(x => x.HighConfidenceModeEnabled).Returns(true);
+ settings.SetupGet(x => x.HighConfidenceThreshold).Returns(0.90);
+ var modelGlobals = new Mock(MockBehavior.Strict);
+ modelGlobals.SetupGet(x => x.QfSettings).Returns(settings.Object);
+
+ IFolderSearchHandler scoredHandler = new Mock().Object;
+ var scoringService = new Mock(MockBehavior.Strict);
+ scoringService
+ .Setup(x =>
+ x.ScoreAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .Returns(Task.FromResult((950L, @"\\Archive\Projects\Active", scoredHandler)));
+
+ // The monitor throws for the first item it is handed and succeeds afterwards, which is
+ // exactly the TryUnhookOrReplace throw branch: remove the failed node, pull a
+ // replacement from the master queue, re-insert it at the same index.
+ var unhookCalls = new List();
+ var moveMonitor = new Mock(MockBehavior.Strict);
+ moveMonitor
+ .Setup(x => x.UnhookItem(It.IsAny()))
+ .Callback(item =>
+ {
+ unhookCalls.Add(item);
+ if (unhookCalls.Count == 1)
+ {
+ throw new InvalidOperationException(
+ "simulated EmailMoveMonitor unhook failure"
+ );
+ }
+ });
+
+ SetPrivateField(model, "_globals", modelGlobals.Object);
+ SetPrivateField(model, "_masterQueue", masterQueue);
+ SetPrivateField(model, "_moveMonitor", moveMonitor.Object);
+ SetPrivateField(model, "_worker", new BackgroundWorker());
+ SetPrivateField(model, "_remainingLoadActive", true);
+ model.ScoringServiceFactory = () => scoringService.Object;
+
+ // ---------------------------------------------------------------------------------
+ // Stage one — act. The quantity of 1 is load-bearing and not a free choice: with 2 the
+ // gate accepts both queued items, _masterQueue.TryTakeFirst() returns null inside
+ // TryUnhookOrReplace, no substitute is inserted, and PreScored would hold two entries
+ // rather than the one the stage-one assertion requires.
+ // ---------------------------------------------------------------------------------
+ QfcDequeueBatch batch = await model.DequeueNextItemGroupWithOutcomeAsync(
+ 1,
+ 0,
+ TimeSpan.FromSeconds(3),
+ null
+ );
+
+ // ---------------------------------------------------------------------------------
+ // Stage one — assert the divergence is real before relying on it.
+ // ---------------------------------------------------------------------------------
+ batch
+ .Items.Should()
+ .ContainSingle(
+ "the throw branch removes the failed item and inserts exactly one substitute"
+ );
+ batch
+ .Items[0]
+ .Should()
+ .BeSameAs(
+ substituteItem,
+ "TryUnhookOrReplace replaces the failed node with the next master-queue entry"
+ );
+ batch
+ .PreScored.Should()
+ .ContainSingle("the gate accepted exactly one candidate before the unhook pass");
+ batch
+ .PreScored[0]
+ .MailItem.Should()
+ .BeSameAs(
+ failedItem,
+ "PreScored is captured before UnhookDequeuedNodes, so it still names the item "
+ + "whose UnhookItem call threw"
+ );
+
+ // ---------------------------------------------------------------------------------
+ // Stage two — arrange: drive the real RunAsync with that exact batch.
+ // ---------------------------------------------------------------------------------
+ var tokenSource = new CancellationTokenSource();
+ _mockProgressTracker = SetupMockProgressTracker(tokenSource);
+ ProgressTracker progress = _mockProgressTracker.Object;
+
+ const int itemsPerIteration = 7;
+ SetupQfSettings(highConfidenceEnabled: true, threshold: 0.90);
+
+ var mockDataModel = new Mock();
+ mockDataModel
+ .Setup(x =>
+ x.InitEmailQueueAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync(new List());
+ mockDataModel
+ .Setup(x =>
+ x.DequeueNextItemGroupWithOutcomeAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )
+ )
+ .ReturnsAsync(batch);
+ mockDataModel.Setup(x => x.Complete).Returns(true);
+ _controller.DataModel = mockDataModel.Object;
+
+ IList loaded = null;
+ var mockFormController = new Mock();
+ mockFormController.SetupGet(x => x.ItemsPerIteration).Returns(itemsPerIteration);
+ mockFormController
+ .Setup(x => x.LoadItemsAsync(It.IsAny>()))
+ .Callback>(carriers => loaded = carriers)
+ .Returns(Task.CompletedTask);
+ SetPrivateField(_controller, "_formController", mockFormController.Object);
+
+ var mockFormViewer = new Mock();
+ mockFormViewer.SetupGet(x => x.Worker).Returns(new BackgroundWorker());
+ SetPrivateField(_controller, "_formViewer", mockFormViewer.Object);
+
+ // ---------------------------------------------------------------------------------
+ // Stage two — act.
+ // ---------------------------------------------------------------------------------
+ await _controller.RunAsync(progress);
+
+ // ---------------------------------------------------------------------------------
+ // Stage two — assert at the consuming boundary. QfcFormController forwards this list to
+ // QfcCollectionController.LoadControlsAndHandlers_01Async, whose body derives the
+ // displayed spine as preScored.Select(x => x.MailItem) and builds one QfcItemGroup per
+ // carrier, so this list IS the displayed set.
+ // ---------------------------------------------------------------------------------
+ loaded
+ .Should()
+ .NotBeNull("RunAsync must invoke the carrier overload in high-confidence mode");
+ loaded
+ .Should()
+ .ContainSingle(
+ "the displayed set must match the one item that survived the unhook"
+ );
+ loaded[0]
+ .MailItem.Should()
+ .BeSameAs(
+ substituteItem,
+ "the substitute left the master queue and is lost for the session unless it is "
+ + "displayed"
+ );
+ loaded
+ .Should()
+ .NotContain(
+ carrier => ReferenceEquals(carrier.MailItem, failedItem),
+ "an item still hooked to the EmailMoveMonitor must never reach the display"
+ );
+ loaded[0]
+ .FolderHandler.Should()
+ .BeNull(
+ "the substitute was pulled from the master queue after scoring, so no carrier "
+ + "was ever built for it and the item controller must fall back to its own "
+ + "scoring pass"
+ );
+ }
+ }
+}
diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.cs b/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.cs
index 51cf796df..0e0fe3af2 100644
--- a/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.cs
+++ b/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.cs
@@ -54,6 +54,25 @@ ProgressTracker progress
)
)
.ReturnsAsync(new List());
+ // Issue #678: enabled-mode RunAsync reads the outcome-returning member, which is the
+ // only overload that surfaces the carriers. The plain overloads above stay configured
+ // so the disabled-mode tests in this class continue to exercise their own path.
+ mockDataModel
+ .Setup(x =>
+ x.DequeueNextItemGroupWithOutcomeAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )
+ )
+ .ReturnsAsync(
+ new QfcDequeueBatch(
+ new List(),
+ new List(),
+ QfcDequeueStop.QuantitySatisfied
+ )
+ );
mockDataModel.Setup(x => x.Complete).Returns(true);
_controller.DataModel = mockDataModel.Object;
@@ -117,6 +136,13 @@ public async Task RunAsync_HighConfidenceEnabled_LoadsFirstPageFromStreamingDequ
var unfilteredInitialBatch = new List { new Mock().Object };
var streamedCandidate = new Mock().Object;
var streamedBatch = new List { streamedCandidate };
+ // Issue #678: the streamed page now arrives as carriers, each pairing the candidate with
+ // the folder handler the gate already initialised for it.
+ var streamedHandler = new Mock().Object;
+ var streamedCarriers = new List
+ {
+ new QfcPreScoredItem(streamedCandidate, @"\\A\streamed", streamedHandler),
+ };
const int itemsPerIteration = 7;
SetupQfSettings(highConfidenceEnabled: true, threshold: 0.90);
@@ -134,16 +160,25 @@ public async Task RunAsync_HighConfidenceEnabled_LoadsFirstPageFromStreamingDequ
.ReturnsAsync(unfilteredInitialBatch);
// Issue #424: the pre-UI call site moved to the deadline+progress overload and adopted
// the 200 ms poll (O1). The sink must be non-null so the ProgressViewer advances.
+ // Issue #678: that call site moved again, to the outcome-returning member. The four
+ // argument constraints are unchanged, so the deadline bound and the progress sink stay
+ // pinned exactly as issue #424 left them.
mockDataModel
.Setup(x =>
- x.DequeueNextItemGroupAsync(
+ x.DequeueNextItemGroupWithOutcomeAsync(
itemsPerIteration,
200,
QfcStreamingDequeueConfidenceGate.DefaultFirstBatchDeadline,
It.Is>(sink => sink != null)
)
)
- .ReturnsAsync(streamedBatch);
+ .ReturnsAsync(
+ new QfcDequeueBatch(
+ streamedBatch,
+ streamedCarriers,
+ QfcDequeueStop.QuantitySatisfied
+ )
+ );
mockDataModel.Setup(x => x.Complete).Returns(true);
_controller.DataModel = mockDataModel.Object;
@@ -152,8 +187,9 @@ public async Task RunAsync_HighConfidenceEnabled_LoadsFirstPageFromStreamingDequ
mockFormController
.Setup(x =>
x.LoadItemsAsync(
- It.Is>(items =>
- items.Count == 1 && ReferenceEquals(items[0], streamedCandidate)
+ It.Is>(carriers =>
+ carriers.Count == 1
+ && ReferenceEquals(carriers[0].MailItem, streamedCandidate)
)
)
)
@@ -179,7 +215,7 @@ public async Task RunAsync_HighConfidenceEnabled_LoadsFirstPageFromStreamingDequ
);
mockDataModel.Verify(
m =>
- m.DequeueNextItemGroupAsync(
+ m.DequeueNextItemGroupWithOutcomeAsync(
itemsPerIteration,
200,
QfcStreamingDequeueConfidenceGate.DefaultFirstBatchDeadline,
@@ -192,20 +228,32 @@ public async Task RunAsync_HighConfidenceEnabled_LoadsFirstPageFromStreamingDequ
mockFormController.Verify(
m =>
m.LoadItemsAsync(
- It.Is>(items =>
- items.Count == 1 && ReferenceEquals(items[0], streamedCandidate)
+ It.Is>(carriers =>
+ carriers.Count == 1
+ && ReferenceEquals(carriers[0].MailItem, streamedCandidate)
+ && ReferenceEquals(carriers[0].FolderHandler, streamedHandler)
)
),
Times.Once,
- "RunAsync must load the streamed high-confidence candidate batch"
+ "RunAsync must load the streamed high-confidence candidate batch as carriers, "
+ + "each still holding the folder handler the gate initialised for it"
);
+ // Issue #678: this must constrain the CARRIER overload. Left on the IList
+ // form it would be satisfied trivially after the change, because that overload is no
+ // longer invoked at all in enabled mode, so the assertion would hold whatever the
+ // production code did with the unfiltered batch.
mockFormController.Verify(
m =>
m.LoadItemsAsync(
- It.Is>(items => items == unfilteredInitialBatch)
+ It.Is>(carriers =>
+ carriers.Count == unfilteredInitialBatch.Count
+ && carriers.Count > 0
+ && ReferenceEquals(carriers[0].MailItem, unfilteredInitialBatch[0])
+ )
),
Times.Never,
- "RunAsync must not load the unfiltered initialization batch"
+ "RunAsync must not load a carrier list projected from the unfiltered "
+ + "initialization batch"
);
}
@@ -278,196 +326,8 @@ public async Task RunAsync_HighConfidenceDisabled_UsesPlainOverloadOnly()
Times.Never
);
}
-
- ///
- /// Issue #424 AC 6: the progress sink RunAsync hands to the dequeue overload maps gate
- /// progress into the controller's 0-30 band. Every report the tracker receives between the
- /// "Initializing Email Queue" and "Initializing Qfc Items" reports must lie within [0, 30]
- /// and the sequence must be monotonically non-decreasing.
- ///
- [TestMethod]
- public async Task RunAsync_HighConfidenceScanProgress_MapsReportsIntoTheZeroToThirtyBand()
- {
- // Arrange
- var tokenSource = new CancellationTokenSource();
- _mockProgressTracker = SetupMockProgressTracker(tokenSource);
- ProgressTracker progress = _mockProgressTracker.Object;
- const int itemsPerIteration = 4;
-
- var reports = new List<(double Value, string Label)>();
- _mockProgressTracker
- .Setup(x => x.Report(It.IsAny(), It.IsAny()))
- .Callback((value, label) => reports.Add((value, label)));
-
- SetupQfSettings(highConfidenceEnabled: true, threshold: 0.90);
-
- var mockDataModel = new Mock();
- mockDataModel
- .Setup(x =>
- x.InitEmailQueueAsync(
- 0,
- It.IsAny(),
- It.IsAny(),
- It.IsAny()
- )
- )
- .ReturnsAsync(new List());
- // The mock captures the sink and drives it with a scripted scan before returning.
- mockDataModel
- .Setup(x =>
- x.DequeueNextItemGroupAsync(
- itemsPerIteration,
- 200,
- It.IsAny(),
- It.IsAny>()
- )
- )
- .Returns(
- (
- int quantity,
- int timeOut,
- TimeSpan deadline,
- System.Action sink
- ) =>
- {
- sink(1, 0, quantity);
- sink(2, 1, quantity);
- sink(3, 1, quantity);
- sink(4, 2, quantity);
- sink(5, 4, quantity);
- return Task.FromResult>(new List());
- }
- );
- mockDataModel.Setup(x => x.Complete).Returns(true);
- _controller.DataModel = mockDataModel.Object;
-
- var mockFormController = new Mock();
- mockFormController.SetupGet(x => x.ItemsPerIteration).Returns(itemsPerIteration);
- mockFormController
- .Setup(x => x.LoadItemsAsync(It.IsAny>()))
- .Returns(Task.CompletedTask);
- SetPrivateField(_controller, "_formController", mockFormController.Object);
-
- var mockFormViewer = new Mock();
- mockFormViewer.SetupGet(x => x.Worker).Returns(new BackgroundWorker());
- SetPrivateField(_controller, "_formViewer", mockFormViewer.Object);
-
- // Act
- await _controller.RunAsync(progress);
-
- // Assert — isolate the reports emitted between the two label reports.
- int start = reports.FindIndex(r => r.Label == "Initializing Email Queue");
- int end = reports.FindIndex(r => r.Label == "Initializing Qfc Items");
- start.Should().BeGreaterThanOrEqualTo(0, "RunAsync opens with the queue-init report");
- end.Should().BeGreaterThan(start, "the Qfc-items report closes the scanning window");
-
- List<(double Value, string Label)> scanReports = reports
- .Skip(start + 1)
- .Take(end - start - 1)
- .ToList();
-
- scanReports.Should().HaveCount(5, "one mapped report per scripted gate signal");
- scanReports
- .Should()
- .OnlyContain(r => r.Value >= 0 && r.Value <= 30, "reports stay inside the band");
- scanReports
- .Should()
- .OnlyContain(r => r.Label.StartsWith("Scanning for high-confidence items"));
- for (int i = 1; i < scanReports.Count; i++)
- {
- scanReports[i]
- .Value.Should()
- .BeGreaterThanOrEqualTo(
- scanReports[i - 1].Value,
- "mapped progress must be monotonically non-decreasing"
- );
- }
-
- reports[start].Value.Should().Be(0);
- reports[end].Value.Should().Be(30);
- }
-
- ///
- /// Issue #424 AC 2: when the deadline expires with nothing accepted, the empty batch still
- /// reaches LoadItemsAsync (an empty list is not short-circuited by the null-guard at
- /// QfcFormController.Actions.cs:68-79) and background iteration is still initiated.
- ///
- [TestMethod]
- public async Task RunAsync_HighConfidenceEmptyBatch_StillLoadsItemsAndStartsIteration()
- {
- // Arrange
- var tokenSource = new CancellationTokenSource();
- _mockProgressTracker = SetupMockProgressTracker(tokenSource);
- ProgressTracker progress = _mockProgressTracker.Object;
- const int itemsPerIteration = 6;
- var sinkInvoked = false;
-
- SetupQfSettings(highConfidenceEnabled: true, threshold: 0.90);
-
- var mockDataModel = new Mock();
- mockDataModel
- .Setup(x =>
- x.InitEmailQueueAsync(
- 0,
- It.IsAny(),
- It.IsAny(),
- It.IsAny()
- )
- )
- .ReturnsAsync(new List());
- mockDataModel
- .Setup(x =>
- x.DequeueNextItemGroupAsync(
- itemsPerIteration,
- 200,
- It.IsAny(),
- It.IsAny>()
- )
- )
- .Returns(
- (
- int quantity,
- int timeOut,
- TimeSpan deadline,
- System.Action sink
- ) =>
- {
- sink(9, 0, quantity);
- sinkInvoked = true;
- return Task.FromResult>(new List());
- }
- );
- mockDataModel.Setup(x => x.Complete).Returns(true);
- _controller.DataModel = mockDataModel.Object;
-
- var mockFormController = new Mock();
- mockFormController.SetupGet(x => x.ItemsPerIteration).Returns(itemsPerIteration);
- mockFormController
- .Setup(x => x.LoadItemsAsync(It.IsAny>()))
- .Returns(Task.CompletedTask);
- SetPrivateField(_controller, "_formController", mockFormController.Object);
-
- var mockFormViewer = new Mock();
- mockFormViewer.SetupGet(x => x.Worker).Returns(new BackgroundWorker());
- SetPrivateField(_controller, "_formViewer", mockFormViewer.Object);
-
- // Act
- await _controller.RunAsync(progress);
-
- // Assert
- sinkInvoked
- .Should()
- .BeTrue("the gate reports scan progress even when nothing qualifies");
- mockFormController.Verify(
- m => m.LoadItemsAsync(It.Is>(items => items.Count == 0)),
- Times.Once,
- "an empty first batch must still reach the form path, not be short-circuited"
- );
- mockDataModel.Verify(
- m => m.Complete,
- Times.AtLeastOnce,
- "background iteration must still be initiated after the empty first batch"
- );
- }
+ // RunAsync_HighConfidenceScanProgress_MapsReportsIntoTheZeroToThirtyBand and
+ // RunAsync_HighConfidenceEmptyBatch_StillLoadsItemsAndStartsIteration live in the
+ // partial part QfcHomeControllerRunAsyncHighConfidenceTests.Part2.cs; see that file.
}
}
diff --git a/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs b/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs
new file mode 100644
index 000000000..d3c80da5f
--- /dev/null
+++ b/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs
@@ -0,0 +1,354 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using QuickFiler.Controllers;
+using UtilitiesCS;
+
+namespace QuickFiler.Controllers.Tests
+{
+ ///
+ /// Issue #678 folder-handling tests: the single-initialisation invariant for a carried folder
+ /// handler, the negative guard that a carried handler is ignored on the
+ /// FromArrayOrString path, and the archive-rooted path-normalisation case. These live in
+ /// a partial part because
+ /// QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.cs is at 498 lines
+ /// and has two lines of headroom to the 500-line cap. No second [TestClass] attribute is
+ /// declared here; the attribute on the base part covers the whole class.
+ ///
+ public partial class QfcItemController_FolderHandlingTests
+ {
+ ///
+ /// Builds a Moq mock of the predictor-construction delegate seam, configured to throw a
+ /// sentinel when invoked. Moq mocks a delegate type directly, so the Times assertion
+ /// AC16 requires is expressible without introducing a new interface.
+ ///
+ private static Mock<
+ Func
+ > BuildThrowingPredictorFactoryMock()
+ {
+ var factory =
+ new Mock<
+ Func
+ >();
+ factory
+ .Setup(f =>
+ f(
+ It.IsAny(),
+ It.IsAny