diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs
new file mode 100644
index 000000000..4a39b3146
--- /dev/null
+++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs
@@ -0,0 +1,463 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using QuickFiler.Controllers;
+using QuickFiler.Viewers;
+using UtilitiesCS;
+using UtilitiesCS.OutlookObjects.Folder;
+
+namespace QuickFiler.Test.Controllers
+{
+ ///
+ /// Headless issue #799 regression tests for the breadcrumb router's score join (AC6), the
+ /// archive-relative filing invariant under a trimmed chain (AC3), and the zero-candidate row
+ /// suppression (AC7). Every collaborator is a Moq seam or a real pure type; this file creates
+ /// no WebView2 control, no Outlook COM object, and no message pump.
+ ///
+ /// Authored for C# 7.3. QuickFiler.Test declares no LangVersion element and targets v4.8.1, so
+ /// it compiles at the 7.3 default while every other project in scope is at Latest, preview, or
+ /// 12.0. Do not introduce target-typed new, is not null, switch expressions, or
+ /// nullable reference annotations here: they surface as CS8370 at build time, not at edit time.
+ ///
+ ///
+ [TestClass]
+ public sealed class BreadcrumbBridgeRouterScoreJoinTests
+ {
+ private const string ArchiveRoot = @"\Archive";
+ private const string RelativeTarget = @"Clients\North";
+ private const string RootedTarget = @"\Archive\Clients\North";
+ private const string ClientsPath = @"\Archive\Clients";
+
+ ///
+ /// AC6: the Efc surface hands the router the RAW score paths, which are archive-rooted,
+ /// while the presented rows are archive-relative stems. The join must still find the score,
+ /// or an archive-rooted suggestion silently loses its percentage.
+ ///
+ [TestMethod]
+ public void BindRowsAsync_RootedScoreAndRelativeRow_RendersThePercentage()
+ {
+ // Arrange
+ var documents = new List();
+ var provider = new Mock(MockBehavior.Strict);
+ var host = StrictHost(documents);
+ FolderTreeNodeKey key = Key(RootedTarget);
+ SetupChain(
+ provider,
+ RootedTarget,
+ key,
+ TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North")
+ );
+ var router = RouterOver(provider, host);
+
+ // Act: presented row is the stem; the score carries the rooted path.
+ router
+ .BindRowsAsync(
+ new[] { RelativeTarget },
+ new[] { new FolderScore(RootedTarget, 730, 0.73) },
+ ArchiveRoot,
+ CancellationToken.None
+ )
+ .GetAwaiter()
+ .GetResult();
+
+ // Assert
+ documents.Should().ContainSingle();
+ documents[0].Should().Contain("73%", "an archive-rooted score must still join");
+ }
+
+ ///
+ /// Decision D7: the projected score key is ADDED alongside the raw key, never substituted
+ /// for it. A substitution would fix the stem-presented case and silently break this one.
+ ///
+ [TestMethod]
+ public void BindRowsAsync_RootedScoreAndRootedRow_StillRendersThePercentage()
+ {
+ // Arrange
+ var documents = new List();
+ var provider = new Mock(MockBehavior.Strict);
+ var host = StrictHost(documents);
+ FolderTreeNodeKey key = Key(RootedTarget);
+ SetupChain(
+ provider,
+ RootedTarget,
+ key,
+ TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North")
+ );
+ var router = RouterOver(provider, host);
+
+ // Act: presented row and score both carry the rooted path.
+ router
+ .BindRowsAsync(
+ new[] { RootedTarget },
+ new[] { new FolderScore(RootedTarget, 730, 0.73) },
+ ArchiveRoot,
+ CancellationToken.None
+ )
+ .GetAwaiter()
+ .GetResult();
+
+ // Assert
+ documents.Should().ContainSingle();
+ documents[0].Should().Contain("73%", "the rooted-presented case must not regress");
+ }
+
+ ///
+ /// The public three-argument overload forwards an empty archive root, so the projection is
+ /// the identity and no existing caller changes behaviour.
+ ///
+ [TestMethod]
+ public void BindRowsAsync_EmptyBoundRoot_LeavesTheJoinUnchanged()
+ {
+ // Arrange
+ var documents = new List();
+ var provider = new Mock(MockBehavior.Strict);
+ var host = StrictHost(documents);
+ FolderTreeNodeKey key = Key(RelativeTarget);
+ SetupChain(
+ provider,
+ RelativeTarget,
+ key,
+ TwoSegmentChain(@"Clients", "Clients", RelativeTarget, "North")
+ );
+ var router = RouterOver(provider, host);
+
+ // Act: the public overload, which supplies no archive root at all.
+ router
+ .BindRowsAsync(
+ new[] { RelativeTarget },
+ new[] { new FolderScore(RelativeTarget, 730, 0.73) },
+ CancellationToken.None
+ )
+ .GetAwaiter()
+ .GetResult();
+
+ // Assert
+ documents.Should().ContainSingle();
+ documents[0].Should().Contain("73%");
+ }
+
+ ///
+ /// AC3, pinned as its own criterion rather than as an incidental consequence of the trim:
+ /// with an ancestor chain that begins BELOW the archive root, the filing target and the
+ /// joined score key are both still the archive-relative stem.
+ ///
+ [TestMethod]
+ public void BindRowsAsync_TrimmedChain_PreservesFilingTargetAndScoreKey()
+ {
+ // Arrange: the chain carries no store segment and no Archive segment.
+ var documents = new List();
+ var provider = new Mock(MockBehavior.Strict);
+ var host = StrictHost(documents);
+ FolderTreeNodeKey key = Key(RootedTarget);
+ SetupChain(
+ provider,
+ RootedTarget,
+ key,
+ TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North")
+ );
+ var router = RouterOver(provider, host);
+
+ // Act
+ router
+ .BindRowsAsync(
+ new[] { RelativeTarget },
+ new[] { new FolderScore(RootedTarget, 730, 0.73) },
+ ArchiveRoot,
+ CancellationToken.None
+ )
+ .GetAwaiter()
+ .GetResult();
+ router
+ .ProcessInboundAsync("{\"type\":\"rowSelected\",\"rowId\":\"row-0\"}")
+ .GetAwaiter()
+ .GetResult();
+
+ // Assert
+ router
+ .SelectedFolderPath.Should()
+ .Be(RelativeTarget, "the filing target stays archive-relative (#439)");
+ documents[documents.Count - 1]
+ .Should()
+ .Contain("73%", "the score key stays joined to the archive-relative stem");
+ }
+
+ ///
+ /// The spec's integration scenario, driven entirely through the router: a banner row, a
+ /// suggestion row, a search-result row, the trash pseudo-row, and one stale label that the
+ /// provider cannot resolve. Lineage renders on both folder row kinds only.
+ ///
+ [TestMethod]
+ public void BindRowsAsync_MixedRowSet_RendersLineageOnFolderRowsOnly()
+ {
+ // Arrange
+ const string searchTarget = @"Search\Follow Up";
+ const string searchRooted = @"\Archive\Search\Follow Up";
+ const string staleTarget = @"Clients\Stale";
+ var documents = new List();
+ var provider = new Mock(MockBehavior.Strict);
+ var host = StrictHost(documents);
+ FolderTreeNodeKey suggestionKey = Key(RootedTarget);
+ FolderTreeNodeKey searchKey = Key(searchRooted);
+ SetupChain(
+ provider,
+ RootedTarget,
+ suggestionKey,
+ TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North")
+ );
+ SetupChain(
+ provider,
+ searchRooted,
+ searchKey,
+ TwoSegmentChain(@"\Archive\Search", "Search", searchRooted, "Follow Up")
+ );
+ provider
+ .Setup(p =>
+ p.ResolveLeafKeyAsync(@"\Archive\Clients\Stale", It.IsAny())
+ )
+ .ReturnsAsync((FolderTreeNodeKey)null);
+ var router = RouterOver(provider, host);
+
+ // Act
+ router
+ .BindRowsAsync(
+ new[]
+ {
+ "==== SUGGESTIONS ====",
+ RelativeTarget,
+ searchTarget,
+ "Trash to Delete",
+ staleTarget,
+ },
+ new[] { new FolderScore(RootedTarget, 730, 0.73) },
+ ArchiveRoot,
+ CancellationToken.None
+ )
+ .GetAwaiter()
+ .GetResult();
+
+ // Assert: exactly two rows render an ancestor separator, and they are the two folder
+ // row kinds; the banner, the trash pseudo-row and the stale label render none.
+ string document = documents[0];
+ Occurrences(document, "class=\"sep\"")
+ .Should()
+ .Be(2, "lineage renders on the suggestion and search rows only");
+ document.Should().Contain("title=\"" + ClientsPath + "\"");
+ document.Should().Contain("title=\"\\Archive\\Search\"");
+ document.Should().Contain("row banner");
+ document.Should().Contain("row selectable trash");
+ document.Should().Contain(">Stale<", "the stale label keeps the leaf-only fallback");
+ }
+
+ ///
+ /// AC7 row half, and the only test here that takes the TRUE arm of the suppression
+ /// predicate. The suppressed row sits in the MIDDLE of the presented sequence, so a
+ /// suppression that removed the row from the built list without removing it from the
+ /// presented list would misalign every later row's segment keys.
+ ///
+ [TestMethod]
+ public void BindRowsAsync_ZeroCandidateLabel_SuppressesTheRowAndKeepsSegmentKeysAligned()
+ {
+ // Arrange
+ const string vendorsTarget = @"Vendors\South";
+ const string vendorsRooted = @"\Archive\Vendors\South";
+ const string vendorsPath = @"\Archive\Vendors";
+ const string staleTarget = @"Clients\Stale";
+ const string staleRooted = @"\Archive\Clients\Stale";
+ var documents = new List();
+ var provider = new Mock(MockBehavior.Strict);
+
+ // Moq throws when an interface is added to a mock whose object already exists, so the
+ // As<> call precedes every use of provider.Object.
+ var absence = provider.As();
+ absence.Setup(a => a.IsAbsentLabel(It.IsAny())).Returns(false);
+ absence.Setup(a => a.IsAbsentLabel(staleRooted)).Returns(true);
+ absence.Setup(a => a.IsAbsentLabel(staleTarget)).Returns(true);
+
+ var host = StrictHost(documents);
+ FolderTreeNodeKey northKey = Key(RootedTarget);
+ FolderTreeNodeKey southKey = Key(vendorsRooted);
+ FolderTreeNodeKey vendorsKey = Key(vendorsPath);
+ SetupChain(
+ provider,
+ RootedTarget,
+ northKey,
+ TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North")
+ );
+ SetupChain(
+ provider,
+ vendorsRooted,
+ southKey,
+ TwoSegmentChain(vendorsPath, "Vendors", vendorsRooted, "South")
+ );
+ provider
+ .Setup(p => p.ResolveLeafKeyAsync(staleRooted, It.IsAny()))
+ .ReturnsAsync((FolderTreeNodeKey)null);
+ provider
+ .Setup(p =>
+ p.GetImmediateSubfoldersAsync(vendorsKey, It.IsAny())
+ )
+ .ReturnsAsync(new[] { Segment(vendorsRooted, "South", false) });
+ var router = RouterOver(provider, host);
+
+ // Act
+ router
+ .BindRowsAsync(
+ new[] { "==== SUGGESTIONS ====", RelativeTarget, staleTarget, vendorsTarget },
+ new[] { new FolderScore(RootedTarget, 730, 0.73) },
+ ArchiveRoot,
+ CancellationToken.None
+ )
+ .GetAwaiter()
+ .GetResult();
+ router
+ .ProcessInboundAsync(
+ "{\"type\":\"segmentActivate\",\"rowId\":\"row-2\",\"segmentIndex\":0}"
+ )
+ .GetAwaiter()
+ .GetResult();
+ router
+ .ProcessInboundAsync("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-2\"}")
+ .GetAwaiter()
+ .GetResult();
+
+ // Assert
+ string document = documents[0];
+ document.Should().NotContain("Stale", "the zero-candidate label is suppressed");
+ Occurrences(document, "data-row-id=\"row-")
+ .Should()
+ .Be(3, "one presented row of four was suppressed");
+ provider.Verify(
+ p => p.GetImmediateSubfoldersAsync(vendorsKey, It.IsAny()),
+ Times.Once
+ );
+ }
+
+ ///
+ /// Decision D-B restricts suppression to the zero-candidate cause. An ambiguous label also
+ /// yields a null chain, but it is not absent and must still render with today's fallback.
+ ///
+ [TestMethod]
+ public void BindRowsAsync_AmbiguousLabel_IsNotSuppressed()
+ {
+ // Arrange
+ const string ambiguousTarget = @"Clients\Stale";
+ const string ambiguousRooted = @"\Archive\Clients\Stale";
+ var documents = new List();
+ var provider = new Mock(MockBehavior.Strict);
+ var absence = provider.As();
+ absence.Setup(a => a.IsAbsentLabel(It.IsAny())).Returns(false);
+
+ var host = StrictHost(documents);
+ FolderTreeNodeKey northKey = Key(RootedTarget);
+ SetupChain(
+ provider,
+ RootedTarget,
+ northKey,
+ TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North")
+ );
+ provider
+ .Setup(p => p.ResolveLeafKeyAsync(ambiguousRooted, It.IsAny()))
+ .ReturnsAsync((FolderTreeNodeKey)null);
+ var router = RouterOver(provider, host);
+
+ // Act
+ router
+ .BindRowsAsync(
+ new[] { RelativeTarget, ambiguousTarget },
+ new[] { new FolderScore(RootedTarget, 730, 0.73) },
+ ArchiveRoot,
+ CancellationToken.None
+ )
+ .GetAwaiter()
+ .GetResult();
+
+ // Assert
+ string document = documents[0];
+ document.Should().Contain(">Stale<", "ambiguity is not absence");
+ Occurrences(document, "data-row-id=\"row-").Should().Be(2);
+ }
+
+ private static BreadcrumbBridgeRouter RouterOver(
+ Mock provider,
+ Mock host
+ )
+ {
+ return new BreadcrumbBridgeRouter(
+ provider.Object,
+ host.Object,
+ new BreadcrumbMessageCodec(),
+ new BreadcrumbHtmlRenderer(),
+ new BreadcrumbOutboundQueue(host.Object)
+ );
+ }
+
+ private static Mock StrictHost(List documents)
+ {
+ var host = new Mock(MockBehavior.Strict);
+ host.SetupGet(h => h.IsCoreInitialized).Returns(true);
+ host.Setup(h => h.NavigateToString(It.IsAny()))
+ .Callback(html => documents.Add(html));
+ host.Setup(h => h.PostMessageJson(It.IsAny()));
+ return host;
+ }
+
+ private static void SetupChain(
+ Mock provider,
+ string hierarchyPath,
+ FolderTreeNodeKey key,
+ IReadOnlyList chain
+ )
+ {
+ provider
+ .Setup(p => p.ResolveLeafKeyAsync(hierarchyPath, It.IsAny()))
+ .ReturnsAsync(key);
+ provider
+ .Setup(p => p.GetAncestorChainAsync(key, It.IsAny()))
+ .ReturnsAsync(chain);
+ }
+
+ ///
+ /// An ancestor chain that already begins below the archive root, which is the shape the
+ /// provider returns once the #799 trim is in place.
+ ///
+ private static IReadOnlyList TwoSegmentChain(
+ string parentPath,
+ string parentName,
+ string leafPath,
+ string leafName
+ )
+ {
+ return new[]
+ {
+ Segment(parentPath, parentName, true),
+ Segment(leafPath, leafName, false),
+ };
+ }
+
+ private static FolderBreadcrumbSegment Segment(string path, string name, bool hasChildren)
+ {
+ return new FolderBreadcrumbSegment(Key(path), name, path, hasChildren);
+ }
+
+ private static FolderTreeNodeKey Key(string path)
+ {
+ return new FolderTreeNodeKey("archive-store", path, path);
+ }
+
+ private static int Occurrences(string haystack, string needle)
+ {
+ int count = 0;
+ int index = haystack.IndexOf(needle, StringComparison.Ordinal);
+ while (index >= 0)
+ {
+ count++;
+ index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal);
+ }
+
+ return count;
+ }
+ }
+}
diff --git a/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs b/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs
index d3c80da5f..87189bdf3 100644
--- a/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs
+++ b/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs
@@ -208,6 +208,12 @@ public void AssignFolderComboBox_WhenArchiveRootedPredeterminedFolder_Preselects
/// path that does not start with the root, a path equal to the root plus a separator with
/// nothing after it, and a case-differing root are each pinned, so the helper cannot be
/// simplified into something that mangles a non-archive path.
+ ///
+ /// Retargeted by issue #799 AC4: an EMPTY archive root is now the identity projection.
+ /// The former behaviour formed an archive prefix of a single separator and stripped it,
+ /// which produced a value that was neither a valid full path nor a valid archive-relative
+ /// stem. The other five boundary cases are unchanged, because the shared projection
+ /// reproduces each of them exactly.
///
[TestMethod]
public void ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProjection()
@@ -220,9 +226,9 @@ public void ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProject
.ProjectPredeterminedFolder(@"\\Archive\Projects\Active", string.Empty)
.Should()
.Be(
- @"\Archive\Projects\Active",
- "a non-null globals with an EMPTY archive root gives FolderPredictor an "
- + "archivePrefix of one separator, which it strips"
+ @"\\Archive\Projects\Active",
+ "AC4 of issue #799 removed the empty-root strip, so an empty archive root "
+ + "is now the identity projection"
);
QfcItemController
.ProjectPredeterminedFolder(null, @"\\Archive")
@@ -243,12 +249,12 @@ public void ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProject
}
///
- /// Issue #678, remediation R2. The boundary case the projection previously got wrong: a
- /// non-null globals whose ArchiveRootPath is EMPTY, with a leading-separator
- /// suggestion path. FolderPredictor.ProjectSuggestionPath guards only on
- /// _globals is null and then forms ArchiveRootPath + "\\" unconditionally, so
- /// in this state its prefix is a single separator and its FolderArray entries ARE
- /// stripped. The carried PredeterminedFolder must be projected the same way, or
+ /// Issue #678, remediation R2, re-derived against the issue #799 AC4 behaviour: a non-null
+ /// globals whose ArchiveRootPath is EMPTY, with a leading-separator suggestion path.
+ /// The shared projection now leaves BOTH the FolderArray entries and the carried
+ /// PredeterminedFolder unchanged in that state, so the two must agree on the
+ /// unstripped value. The invariant under test is unchanged and is the one that matters:
+ /// the carried value must be projected exactly as the array entries are, or
/// FolderContains misses and the selection falls back to the index-1 entry — the
/// exact AC12 defect the change set out to close.
///
@@ -261,7 +267,10 @@ public void AssignFolderComboBox_WhenEmptyArchiveRootAndLeadingSeparator_Presele
{
// Arrange
const string RawSuggestion = @"\Projects\Active";
- const string ProjectedSuggestion = @"Projects\Active";
+
+ // #799 AC4: an empty archive root is the identity projection, so the projected value
+ // and the raw value are the same string.
+ const string ProjectedSuggestion = RawSuggestion;
var mock = new Mock();
mock.SetupGet(v => v.InvokeRequired).Returns(false);
@@ -288,8 +297,8 @@ public void AssignFolderComboBox_WhenEmptyArchiveRootAndLeadingSeparator_Presele
mock.Verify(
v => v.SetFolderSelectedItem(ProjectedSuggestion),
Times.Once(),
- "an empty archive root still strips the leading separator in FolderPredictor, so "
- + "the carried value must be stripped the same way to match"
+ "an empty archive root is the identity projection in FolderPredictor, so the "
+ + "carried value must be carried through the same way to match"
);
mock.Verify(
v => v.SetFolderSelectedIndex(It.IsAny()),
diff --git a/QuickFiler.Test/QuickFiler.Test.csproj b/QuickFiler.Test/QuickFiler.Test.csproj
index 8a3a30030..f0d479502 100644
--- a/QuickFiler.Test/QuickFiler.Test.csproj
+++ b/QuickFiler.Test/QuickFiler.Test.csproj
@@ -63,6 +63,7 @@
+
diff --git a/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs b/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs
index 0232cdf90..e8d1dedd9 100644
--- a/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs
+++ b/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs
@@ -23,6 +23,12 @@ public sealed partial class BreadcrumbBridgeRouter
);
private readonly IFolderHierarchyProvider _provider;
+
+ // #799 AC7: obtained by an `as` cast in the constructor, so no constructor signature
+ // changes and no existing test breaks. A Mock<IFolderHierarchyProvider> is not an
+ // IFolderLabelAbsenceReport, so this stays null and suppression is inert in every existing
+ // router test.
+ private readonly IFolderLabelAbsenceReport? _absenceReport;
private readonly IBreadcrumbWebHost _host;
private readonly BreadcrumbMessageCodec _codec;
private readonly BreadcrumbHtmlRenderer _renderer;
@@ -47,6 +53,7 @@ BreadcrumbOutboundQueue outboundQueue
)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ _absenceReport = provider as IFolderLabelAbsenceReport;
_host = host ?? throw new ArgumentNullException(nameof(host));
_codec = codec ?? throw new ArgumentNullException(nameof(codec));
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
@@ -104,6 +111,7 @@ CancellationToken cancellationToken
var chains = new Dictionary>(
StringComparer.OrdinalIgnoreCase
);
+ HashSet? suppressed = null;
_boundRoot = string.IsNullOrWhiteSpace(archiveRootPath)
? string.Empty
: archiveRootPath.TrimEnd('\\', '/');
@@ -126,15 +134,34 @@ CancellationToken cancellationToken
if (chain != null)
{
chains[text] = chain;
+ continue;
+ }
+
+ // #799 AC7 (Efc surface only, per decision D5). A null chain arising from
+ // cancellation or from a provider fault is NOT suppressed: those rows are not
+ // known-absent, and only the zero-candidate classification is.
+ if (
+ hierarchyPath != null
+ && _absenceReport != null
+ && _absenceReport.IsAbsentLabel(hierarchyPath)
+ )
+ {
+ suppressed ??= new HashSet(StringComparer.OrdinalIgnoreCase);
+ suppressed.Add(text);
}
}
+ IReadOnlyList retainedRows = RetainedRows(presentedRows, suppressed);
_rows = _builder.BuildRows(
- presentedRows,
+ retainedRows,
text => chains.TryGetValue(text, out var chain) ? chain : null,
- scores
+ WithProjectedScoreKeys(scores)
);
- AttachSegmentKeys(presentedRows, chains);
+
+ // The SAME retained list is handed to both calls: AttachSegmentKeys indexes the
+ // presented rows by row index, so an unfiltered list here would mis-align every row
+ // after the suppressed one.
+ AttachSegmentKeys(retainedRows, chains);
_selectedRowId = null;
// #499: the rows just rebuilt are a new set, so a folder path selected against the
@@ -149,6 +176,82 @@ CancellationToken cancellationToken
DeliverDocument();
}
+ ///
+ /// AC6: emits every original score UNCHANGED and, additionally, one archive-relative alias
+ /// for each score whose path is archive-rooted. The addition is what makes it safe — a
+ /// substitution would fix the stem-presented case and silently break the rooted-presented
+ /// case — and the row builder's probability index assigns through its indexer, so a
+ /// duplicate key is tolerated rather than throwing.
+ ///
+ private IEnumerable WithProjectedScoreKeys(IEnumerable scores)
+ {
+ // An empty bound root makes the projection the identity, so the public three-argument
+ // overload's callers see no change and allocate nothing. A null sequence is passed
+ // through, null-forgiving, so the row builder keeps raising its own
+ // ArgumentNullException rather than this method raising a different one.
+ if (scores == null || _boundRoot.Length == 0)
+ {
+ return scores!;
+ }
+
+ var joined = new List();
+ foreach (FolderScore score in scores)
+ {
+ joined.Add(score);
+ if (score.FolderPath == null)
+ {
+ continue;
+ }
+
+ // Null-forgiving: ToDisplayStem returns null only for a null folderPath, which the
+ // guard above excludes; unsuppressed the construction below is CS8604.
+ string projected = ArchiveStemProjection.ToDisplayStem(
+ score.FolderPath,
+ _boundRoot
+ )!;
+ if (!string.Equals(projected, score.FolderPath, StringComparison.Ordinal))
+ {
+ joined.Add(new FolderScore(projected, score.Score, score.Probability));
+ }
+ }
+
+ return joined;
+ }
+
+ ///
+ /// AC7: the presented sequence with the known-absent labels removed, filtered BEFORE row
+ /// construction because row ids are assigned as row-<index> over this sequence.
+ /// Returns the original instance when nothing was suppressed.
+ ///
+ private IReadOnlyList RetainedRows(
+ IReadOnlyList presentedRows,
+ HashSet? suppressed
+ )
+ {
+ if (suppressed == null || suppressed.Count == 0)
+ {
+ return presentedRows;
+ }
+
+ var retained = new List(presentedRows.Count);
+ foreach (string text in presentedRows)
+ {
+ if (!string.IsNullOrEmpty(text) && suppressed.Contains(text))
+ {
+ continue;
+ }
+
+ // Null-forgiving: a null entry is carried through exactly as the unfiltered list
+ // carried it, so the row builder's handling of it is unchanged.
+ retained.Add(text!);
+ }
+
+ log.Debug(
+ $"#799 AC7: suppressed {suppressed.Count} zero-candidate breadcrumb row(s) of {presentedRows.Count} presented."
+ );
+ return retained;
+ }
+
private string? ToHierarchyPath(string presentedTarget)
{
// #609 preserved: a RELATIVE presented target stays root-prefixed for the lookup.
diff --git a/QuickFiler/Controllers/EfcFormController.cs b/QuickFiler/Controllers/EfcFormController.cs
index d8610ed44..9019b939d 100644
--- a/QuickFiler/Controllers/EfcFormController.cs
+++ b/QuickFiler/Controllers/EfcFormController.cs
@@ -1051,7 +1051,8 @@ private void ConfigureBreadcrumbControl()
new WebView2CoreInitializer()
);
var provider = new UtilitiesCS.OutlookObjects.Folder.OutlookFolderHierarchyProvider(
- _globals.Ol.FolderTreeService
+ _globals.Ol.FolderTreeService,
+ () => _globals.Ol.ArchiveRootPath
);
_router = new BreadcrumbBridgeRouter(
provider,
diff --git a/QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs b/QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs
new file mode 100644
index 000000000..cccffb320
--- /dev/null
+++ b/QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs
@@ -0,0 +1,41 @@
+using QuickFiler.Viewers;
+
+namespace QuickFiler.Controllers
+{
+ internal partial class QfcItemController
+ {
+ // #351: idempotently creates the host-neutral breadcrumb pipeline on the concrete viewer
+ // so folder population/selection are correct even before WebView2 core init completes.
+ // The 9101 provider is DI-resolved from the injected globals' folder-tree service seam —
+ // no live Outlook query is issued inside breadcrumb code (G6). Skipped for mock viewers
+ // (unit tests drive the coordinator directly through its own seams).
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal void EnsureBreadcrumbPipeline()
+ {
+ if (!(_itemViewer is ItemViewer viewer))
+ {
+ return;
+ }
+
+ if (viewer.BreadcrumbCoordinator == null)
+ {
+ var provider = new UtilitiesCS.OutlookObjects.Folder.OutlookFolderHierarchyProvider(
+ _globals.Ol.FolderTreeService,
+ () => _globals.Ol.ArchiveRootPath
+ );
+ viewer.InitializeBreadcrumbPipeline(provider);
+ }
+
+ if (!ReferenceEquals(_breadcrumbViewer, viewer))
+ {
+ if (_breadcrumbViewer != null)
+ {
+ _breadcrumbViewer.BreadcrumbUnhandledArrow -= OnBreadcrumbUnhandledArrow;
+ }
+ _breadcrumbViewer = viewer;
+ _breadcrumbViewer.BreadcrumbUnhandledArrow -= OnBreadcrumbUnhandledArrow;
+ _breadcrumbViewer.BreadcrumbUnhandledArrow += OnBreadcrumbUnhandledArrow;
+ }
+ }
+ }
+}
diff --git a/QuickFiler/Controllers/QfcItemController.FolderHandling.cs b/QuickFiler/Controllers/QfcItemController.FolderHandling.cs
index ffb3b1b2c..d11e691de 100644
--- a/QuickFiler/Controllers/QfcItemController.FolderHandling.cs
+++ b/QuickFiler/Controllers/QfcItemController.FolderHandling.cs
@@ -224,10 +224,10 @@ public void AssignFolderComboBox()
// FolderPredictor.ProjectSuggestionPath, while the carried PredeterminedFolder is
// the RAW suggestion path the scorer read from Suggestions. Without projecting the
// carried value the same way, FolderContains misses every archive-rooted
- // suggestion and the selection silently falls back to the index-1 entry. The
- // projection is duplicated here rather than reused because
- // FolderPredictor.ProjectSuggestionPath is private and lives under UtilitiesCS,
- // which this change may not modify.
+ // suggestion and the selection silently falls back to the index-1 entry. #799 AC4:
+ // both sides now route through the one shared projection
+ // ArchiveStemProjection.ToDisplayStem, so they agree by construction rather than by
+ // duplication.
string predetermined = ProjectPredeterminedFolder(
_predeterminedFolder,
_globals is null ? null : (_globals.Ol?.ArchiveRootPath ?? string.Empty)
@@ -250,38 +250,21 @@ public void AssignFolderComboBox()
}
///
- /// #678 AC12. Projects a raw suggestion path onto the form FolderPredictor.FolderArray
- /// stores, so a containment probe against the combo box can match: strip
- /// plus a trailing separator from the front of
- /// , case-insensitively, but only when the remainder is
- /// non-empty. #678 R2: the projection mirrors FolderPredictor.ProjectSuggestionPath
- /// for every non-null and non-null
- /// . A NULL stands for
- /// that member's _globals is null guard and yields the identity; an EMPTY one does
- /// not, because that member forms its prefix unconditionally and so strips a single leading
- /// separator in that state.
- ///
- /// Two divergences from that member remain and are deliberate, and both are null-safety
- /// differences rather than projection differences. First, a null or empty
- /// is returned unchanged rather than dereferenced;
- /// ProjectSuggestionPath does not guard it because its input comes from
- /// Suggestions. Second, a non-null globals with a null Ol is treated by the
- /// call site as an empty archive root rather than reproducing that member's null
- /// dereference.
+ /// #678 AC12, re-derived under #799 AC4. Projects a raw suggestion path onto the form
+ /// FolderPredictor.FolderArray stores, so a containment probe against the combo box
+ /// can match. This member and FolderPredictor.ProjectSuggestionPath now share ONE
+ /// projection, ArchiveStemProjection.ToDisplayStem, so they agree by construction
+ /// rather than by duplication, and the empty-root one-separator strip that used to make
+ /// them diverge was eliminated by AC4: a null, empty, or whitespace-only
+ /// is now the identity projection, as is a null or empty
+ /// and any path that is not strictly under the root.
///
internal static string ProjectPredeterminedFolder(string folderPath, string archiveRootPath)
{
- if (string.IsNullOrEmpty(folderPath) || archiveRootPath is null)
- {
- return folderPath;
- }
-
- string archivePrefix = archiveRootPath + "\\";
- return
- folderPath.StartsWith(archivePrefix, StringComparison.OrdinalIgnoreCase)
- && folderPath.Length > archivePrefix.Length
- ? folderPath.Substring(archivePrefix.Length)
- : folderPath;
+ return UtilitiesCS.OutlookObjects.Folder.ArchiveStemProjection.ToDisplayStem(
+ folderPath,
+ archiveRootPath
+ );
}
///
diff --git a/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs b/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs
index 7fefde65f..f10797758 100644
--- a/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs
+++ b/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs
@@ -129,39 +129,6 @@ await ConfigureAndAttachBreadcrumbAsync(
//}, Token, TaskContinuationOptions.OnlyOnRanToCompletion, ui);
}
- // #351: idempotently creates the host-neutral breadcrumb pipeline on the concrete viewer
- // so folder population/selection are correct even before WebView2 core init completes.
- // The 9101 provider is DI-resolved from the injected globals' folder-tree service seam —
- // no live Outlook query is issued inside breadcrumb code (G6). Skipped for mock viewers
- // (unit tests drive the coordinator directly through its own seams).
- [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
- internal void EnsureBreadcrumbPipeline()
- {
- if (!(_itemViewer is ItemViewer viewer))
- {
- return;
- }
-
- if (viewer.BreadcrumbCoordinator == null)
- {
- var provider = new UtilitiesCS.OutlookObjects.Folder.OutlookFolderHierarchyProvider(
- _globals.Ol.FolderTreeService
- );
- viewer.InitializeBreadcrumbPipeline(provider);
- }
-
- if (!ReferenceEquals(_breadcrumbViewer, viewer))
- {
- if (_breadcrumbViewer != null)
- {
- _breadcrumbViewer.BreadcrumbUnhandledArrow -= OnBreadcrumbUnhandledArrow;
- }
- _breadcrumbViewer = viewer;
- _breadcrumbViewer.BreadcrumbUnhandledArrow -= OnBreadcrumbUnhandledArrow;
- _breadcrumbViewer.BreadcrumbUnhandledArrow += OnBreadcrumbUnhandledArrow;
- }
- }
-
/// Configures the lazy popup with the existing environment and active theme.
internal void ConfigureBreadcrumbDropDown(
ItemViewer viewer,
diff --git a/QuickFiler/QuickFiler.csproj b/QuickFiler/QuickFiler.csproj
index 47a5c27cc..35def2f76 100644
--- a/QuickFiler/QuickFiler.csproj
+++ b/QuickFiler/QuickFiler.csproj
@@ -333,6 +333,7 @@
+
diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs
new file mode 100644
index 000000000..e89a70ef7
--- /dev/null
+++ b/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs
@@ -0,0 +1,217 @@
+using System.Collections.Generic;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using UtilitiesCS.OutlookObjects.Folder;
+
+namespace UtilitiesCS.Test.OutlookObjects.Folder
+{
+ ///
+ /// Unit tests for and its single member
+ /// TryTrimBelowArchiveRoot, the ancestor-chain trim introduced by issue #799 (AC1, AC2).
+ /// Every chain is built from literals through the
+ /// four-argument constructor, so there is no snapshot, no provider, no COM, and no mock: the
+ /// unit under test is the trim itself and nothing else.
+ ///
+ [TestClass]
+ public sealed class ArchiveChainProjectionTests
+ {
+ private const string StorePath = "\\\\Mailbox - User";
+ private const string ArchiveRoot = "\\\\Mailbox - User\\Archive";
+ private const string ClientsPath = "\\\\Mailbox - User\\Archive\\Clients";
+ private const string AcmePath = "\\\\Mailbox - User\\Archive\\Clients\\Acme";
+
+ ///
+ /// The ordinary case: the chain passes through the archive root, so the trim yields exactly
+ /// the segments after that node. Segment identity is preserved by reference, which proves
+ /// the trim is a projection over the existing chain rather than a rebuild that could drop a
+ /// key or a display name.
+ ///
+ [TestMethod]
+ public void TryTrimBelowArchiveRoot_ChainPassesThroughRoot_ReturnsSegmentsAfterTheRoot()
+ {
+ // Arrange
+ var chain = FullChain();
+
+ // Act
+ bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot(
+ chain,
+ ArchiveRoot,
+ out var trimmed
+ );
+
+ // Assert
+ trimmedOk.Should().BeTrue();
+ trimmed.Should().HaveCount(2);
+ trimmed[0].Should().BeSameAs(chain[2], "segment identity is preserved by reference");
+ trimmed[1].Should().BeSameAs(chain[3]);
+ }
+
+ ///
+ /// A chain that never reaches the archive root is the AC2 diagnostic case: the trim reports
+ /// failure and yields nothing, so the caller can log once and fall back.
+ ///
+ [TestMethod]
+ public void TryTrimBelowArchiveRoot_ChainMissesTheRoot_ReturnsFalseAndEmptyOutput()
+ {
+ // Arrange
+ var chain = new List
+ {
+ Segment("inbox", StorePath + "\\Inbox", "Inbox"),
+ Segment("inbox-clients", StorePath + "\\Inbox\\Clients", "Clients"),
+ };
+
+ // Act
+ bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot(
+ chain,
+ ArchiveRoot,
+ out var trimmed
+ );
+
+ // Assert
+ trimmedOk.Should().BeFalse();
+ trimmed.Should().BeEmpty();
+ }
+
+ ///
+ /// When the LEAF is the archive root there is nothing below it to render, so the trim
+ /// reports failure rather than returning an empty lineage that would render as a blank row.
+ ///
+ [TestMethod]
+ public void TryTrimBelowArchiveRoot_LeafIsTheRoot_ReturnsFalseAndEmptyOutput()
+ {
+ // Arrange
+ var chain = new List
+ {
+ Segment("store", StorePath, "Mailbox - User"),
+ Segment("archive", ArchiveRoot, "Archive"),
+ };
+
+ // Act
+ bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot(
+ chain,
+ ArchiveRoot,
+ out var trimmed
+ );
+
+ // Assert
+ trimmedOk.Should().BeFalse();
+ trimmed.Should().BeEmpty();
+ }
+
+ /// An empty chain has no archive-root node and therefore reports failure.
+ [TestMethod]
+ public void TryTrimBelowArchiveRoot_EmptyChain_ReturnsFalseAndEmptyOutput()
+ {
+ // Arrange
+ var chain = new List();
+
+ // Act
+ bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot(
+ chain,
+ ArchiveRoot,
+ out var trimmed
+ );
+
+ // Assert
+ trimmedOk.Should().BeFalse();
+ trimmed.Should().BeEmpty();
+ }
+
+ ///
+ /// A single-element chain that IS the archive root is the degenerate form of the
+ /// leaf-is-the-root case and must behave identically.
+ ///
+ [TestMethod]
+ public void TryTrimBelowArchiveRoot_SingleElementChainIsTheRoot_ReturnsFalse()
+ {
+ // Arrange
+ var chain = new List
+ {
+ Segment("archive", ArchiveRoot, "Archive"),
+ };
+
+ // Act
+ bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot(
+ chain,
+ ArchiveRoot,
+ out var trimmed
+ );
+
+ // Assert
+ trimmedOk.Should().BeFalse();
+ trimmed.Should().BeEmpty();
+ }
+
+ /// A root supplied with a trailing separator trims identically.
+ [TestMethod]
+ public void TryTrimBelowArchiveRoot_RootWithTrailingSeparator_ReturnsSegmentsAfterTheRoot()
+ {
+ // Arrange
+ var chain = FullChain();
+
+ // Act
+ bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot(
+ chain,
+ ArchiveRoot + "\\",
+ out var trimmed
+ );
+
+ // Assert
+ trimmedOk.Should().BeTrue();
+ trimmed.Should().HaveCount(2);
+ trimmed[0].FolderPath.Should().Be(ClientsPath);
+ }
+
+ ///
+ /// The #614 false-prefix case at chain level: a lineage under a sibling folder named
+ /// Archive2 must not be treated as passing through the root named Archive.
+ ///
+ [TestMethod]
+ public void TryTrimBelowArchiveRoot_FalsePrefixSiblingArchive2_ReturnsFalse()
+ {
+ // Arrange
+ var chain = new List
+ {
+ Segment("archive2", StorePath + "\\Archive2", "Archive2"),
+ Segment("archive2-clients", StorePath + "\\Archive2\\Clients", "Clients"),
+ };
+
+ // Act
+ bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot(
+ chain,
+ ArchiveRoot,
+ out var trimmed
+ );
+
+ // Assert
+ trimmedOk.Should().BeFalse("the separator-boundary test rejects a false prefix");
+ trimmed.Should().BeEmpty();
+ }
+
+ /// Store node, archive root, one intermediate folder, and the leaf.
+ private static List FullChain()
+ {
+ return new List
+ {
+ Segment("store", StorePath, "Mailbox - User"),
+ Segment("archive", ArchiveRoot, "Archive"),
+ Segment("clients", ClientsPath, "Clients"),
+ Segment("acme", AcmePath, "Acme"),
+ };
+ }
+
+ private static FolderBreadcrumbSegment Segment(
+ string entryId,
+ string folderPath,
+ string displayName
+ )
+ {
+ return new FolderBreadcrumbSegment(
+ new FolderTreeNodeKey("store-a", entryId, folderPath),
+ displayName,
+ folderPath,
+ false
+ );
+ }
+ }
+}
diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs
new file mode 100644
index 000000000..9b7338a4f
--- /dev/null
+++ b/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs
@@ -0,0 +1,176 @@
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using UtilitiesCS.OutlookObjects.Folder;
+
+namespace UtilitiesCS.Test.OutlookObjects.Folder
+{
+ ///
+ /// Unit tests for and its single member
+ /// ToDisplayStem, the lenient display projection introduced by issue #799.
+ /// The projection yields the archive-relative stem
+ /// only when the path is strictly under the configured root, and returns its input unchanged in
+ /// every other case. The boundary cases pinned here are the #614 cases the strict
+ /// already enforces, restated at the display boundary because
+ /// the fallback there is the opposite one: show the caller's own text rather than nothing.
+ /// Pure assertions only: no mocks, no COM, no filesystem, and no temporary file.
+ ///
+ [TestClass]
+ public sealed class ArchiveStemProjectionTests
+ {
+ private const string ArchiveRoot = "\\\\Mailbox - User\\Archive";
+ private const string UnderRoot = "\\\\Mailbox - User\\Archive\\Clients\\Acme";
+ private const string ExpectedStem = "Clients\\Acme";
+
+ /// The ordinary case: a path strictly under the root projects to its stem.
+ [TestMethod]
+ public void ToDisplayStem_PathStrictlyUnderRoot_ReturnsArchiveRelativeStem()
+ {
+ // Arrange, Act
+ var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, ArchiveRoot);
+
+ // Assert
+ projected.Should().Be(ExpectedStem);
+ }
+
+ ///
+ /// A path EQUAL to the root is not projectable: the strict contract reports success with an
+ /// empty stem, and an empty display row is worse than the full path, so the input is
+ /// returned unchanged.
+ ///
+ [TestMethod]
+ public void ToDisplayStem_PathEqualsRoot_ReturnsInputUnchanged()
+ {
+ // Arrange, Act
+ var projected = ArchiveStemProjection.ToDisplayStem(ArchiveRoot, ArchiveRoot);
+
+ // Assert
+ projected
+ .Should()
+ .Be(ArchiveRoot, "an empty display row is worse than the unprojected path");
+ }
+
+ ///
+ /// The #614 false-prefix case. A sibling folder named Archive2 tested against a root ending
+ /// in Archive yields the character 2 at the root's length, which is not a separator, so the
+ /// path is NOT under the root and is not projected.
+ ///
+ [TestMethod]
+ public void ToDisplayStem_FalsePrefixSiblingArchive2_ReturnsInputUnchanged()
+ {
+ // Arrange
+ const string sibling = "\\\\Mailbox - User\\Archive2\\Clients";
+
+ // Act
+ var projected = ArchiveStemProjection.ToDisplayStem(sibling, ArchiveRoot);
+
+ // Assert
+ projected.Should().Be(sibling, "the separator-boundary test rejects a false prefix");
+ }
+
+ /// A root supplied with one trailing separator projects identically.
+ [TestMethod]
+ public void ToDisplayStem_RootWithOneTrailingSeparator_ReturnsArchiveRelativeStem()
+ {
+ // Arrange, Act
+ var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, ArchiveRoot + "\\");
+
+ // Assert
+ projected.Should().Be(ExpectedStem);
+ }
+
+ /// A root supplied with two trailing separators projects identically.
+ [TestMethod]
+ public void ToDisplayStem_RootWithTwoTrailingSeparators_ReturnsArchiveRelativeStem()
+ {
+ // Arrange, Act
+ var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, ArchiveRoot + "\\\\");
+
+ // Assert
+ projected.Should().Be(ExpectedStem);
+ }
+
+ ///
+ /// AC4 of issue #799: an empty archive root leaves the input unchanged. The previous
+ /// per-site logic stripped one leading separator in this case, which produced a path that
+ /// was neither a valid full path nor a valid archive-relative stem.
+ ///
+ [TestMethod]
+ public void ToDisplayStem_EmptyRoot_ReturnsInputUnchanged()
+ {
+ // Arrange, Act
+ var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, string.Empty);
+
+ // Assert
+ projected
+ .Should()
+ .Be(UnderRoot, "#799 AC4 removed the empty-root one-separator strip");
+ }
+
+ /// A whitespace-only root is treated exactly as an empty root.
+ [TestMethod]
+ public void ToDisplayStem_WhitespaceOnlyRoot_ReturnsInputUnchanged()
+ {
+ // Arrange, Act
+ var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, " ");
+
+ // Assert
+ projected.Should().Be(UnderRoot);
+ }
+
+ /// A null path is returned unchanged rather than throwing.
+ [TestMethod]
+ public void ToDisplayStem_NullPath_ReturnsNull()
+ {
+ // Arrange, Act
+ var projected = ArchiveStemProjection.ToDisplayStem(null, ArchiveRoot);
+
+ // Assert
+ projected.Should().BeNull();
+ }
+
+ /// An empty path is returned unchanged rather than projected.
+ [TestMethod]
+ public void ToDisplayStem_EmptyPath_ReturnsInputUnchanged()
+ {
+ // Arrange, Act
+ var projected = ArchiveStemProjection.ToDisplayStem(string.Empty, ArchiveRoot);
+
+ // Assert
+ projected.Should().Be(string.Empty);
+ }
+
+ ///
+ /// Forward-slash separators on both parameters project exactly as backslash separators do,
+ /// because the underlying contract treats both characters as separators.
+ ///
+ [TestMethod]
+ public void ToDisplayStem_ForwardSlashSeparators_ReturnsArchiveRelativeStem()
+ {
+ // Arrange
+ const string root = "//Mailbox - User/Archive";
+ const string path = "//Mailbox - User/Archive/Clients/Acme";
+
+ // Act
+ var projected = ArchiveStemProjection.ToDisplayStem(path, root);
+
+ // Assert
+ projected.Should().Be("Clients/Acme");
+ }
+
+ ///
+ /// The prefix comparison is ordinal case-insensitive, so a mixed-case root still projects.
+ ///
+ [TestMethod]
+ public void ToDisplayStem_MixedCaseRoot_ReturnsArchiveRelativeStem()
+ {
+ // Arrange
+ const string mixedCaseRoot = "\\\\mailbox - USER\\aRcHiVe";
+
+ // Act
+ var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, mixedCaseRoot);
+
+ // Assert
+ projected.Should().Be(ExpectedStem);
+ }
+ }
+}
diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs
new file mode 100644
index 000000000..cd93bf1e5
--- /dev/null
+++ b/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs
@@ -0,0 +1,212 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using UtilitiesCS.ReusableTypeClasses.SerializableNew.Concurrent.Observable;
+using Outlook = Microsoft.Office.Interop.Outlook;
+using OutlookFolder = Microsoft.Office.Interop.Outlook.Folder;
+using OutlookFolders = Microsoft.Office.Interop.Outlook.Folders;
+
+namespace UtilitiesCS.Test.OutlookObjects.Folder
+{
+ ///
+ /// Tests for AC5 of issue #799: recent-folder entries pass through the same archive-stem
+ /// display projection as suggestion entries before they are displayed. Today an
+ /// archive-rooted recent entry is appended verbatim, so a recent selection renders as a full
+ /// store path beside suggestions that render as archive-relative stems.
+ ///
+ /// Every test seeds the recents list with one archive-rooted entry AND one already-relative
+ /// entry, so the projection is observable and the identity case is pinned by the same fixture.
+ /// The mocked-Outlook harness mirrors FolderRowTests; no live Outlook process, COM
+ /// server, or temporary file is used.
+ ///
+ ///
+ [TestClass]
+ public sealed class FolderPredictorRecentsProjectionTests
+ {
+ private const string ArchiveRootPath = "\\\\ArchiveRoot";
+ private const string RootedRecent = "\\\\ArchiveRoot\\Recent\\One";
+ private const string RootedRecentStem = "Recent\\One";
+ private const string RelativeRecent = "Recent\\Two";
+ private const string OutOfRootRecent = "\\\\OtherRoot\\Recent\\Three";
+ private const string RecentsSeparator = "======= RECENT SELECTIONS ========";
+
+ ///
+ /// AC5 on the legacy string surface: the archive-rooted recent entry is projected to its
+ /// archive-relative stem, and the already-relative entry is left alone.
+ ///
+ [TestMethod]
+ public void FolderArray_RootedRecentEntry_IsProjectedToTheArchiveRelativeStem()
+ {
+ // Arrange
+ var predictor = PredictorWithRecents(RootedRecent, RelativeRecent);
+
+ // Act
+ var folderArray = predictor.FolderArray;
+
+ // Assert
+ folderArray.Should().Equal(RecentsSeparator, RootedRecentStem, RelativeRecent);
+ }
+
+ ///
+ /// AC5 on the row-model surface: FolderRowArray projects the archive-rooted recent
+ /// entry identically, and still tags both entries as recent selections.
+ ///
+ [TestMethod]
+ public void FolderRowArray_RootedRecentEntry_IsProjectedToTheArchiveRelativeStem()
+ {
+ // Arrange
+ var predictor = PredictorWithRecents(RootedRecent, RelativeRecent);
+
+ // Act
+ var rows = predictor.FolderRowArray;
+
+ // Assert
+ rows.Select(r => r.Text)
+ .Should()
+ .Equal(RecentsSeparator, RootedRecentStem, RelativeRecent);
+ rows.Where(r => r.Kind == UtilitiesCS.FolderRowKind.Recent)
+ .Select(r => r.Text)
+ .Should()
+ .Equal(RootedRecentStem, RelativeRecent);
+ }
+
+ ///
+ /// The text-parity contract documented on FolderRowArray is currently unasserted for
+ /// recents. Projecting one surface without the other would break it silently, so parity is
+ /// pinned here as its own criterion rather than as a side effect of the two tests above.
+ ///
+ [TestMethod]
+ public void FolderRowArray_AndFolderArray_AgreeOnRecentTextAfterProjection()
+ {
+ // Arrange
+ var predictor = PredictorWithRecents(RootedRecent, RelativeRecent);
+
+ // Act
+ var folderArray = predictor.FolderArray;
+ var rows = predictor.FolderRowArray;
+
+ // Assert
+ rows.Select(r => r.Text)
+ .Should()
+ .Equal(folderArray, "the row model mirrors FolderArray byte for byte");
+ }
+
+ ///
+ /// The projection is lenient: a recent entry that is NOT under the archive root is left
+ /// exactly as stored, because there is no stem to show and the full path is the only
+ /// meaningful text.
+ ///
+ [TestMethod]
+ public void FolderArray_OutOfRootRecentEntry_IsLeftUnchanged()
+ {
+ // Arrange
+ var predictor = PredictorWithRecents(RootedRecent, RelativeRecent, OutOfRootRecent);
+
+ // Act
+ var folderArray = predictor.FolderArray;
+
+ // Assert
+ folderArray
+ .Should()
+ .Equal(RecentsSeparator, RootedRecentStem, RelativeRecent, OutOfRootRecent);
+ }
+
+ private static UtilitiesCS.FolderPredictor PredictorWithRecents(params string[] recents)
+ {
+ var archiveRoot = CreateFolder(
+ ArchiveRootPath,
+ new Dictionary()
+ );
+ var app = CreateApplication(
+ new Dictionary { ["ArchiveRoot"] = archiveRoot.Object }
+ );
+ var globals = CreateGlobals(app, archiveRoot.Object, recents);
+ return new UtilitiesCS.FolderPredictor(globals.Object);
+ }
+
+ // ---- Mocked-Outlook harness (mirrors FolderRowTests) ----
+
+ private static Mock CreateGlobals(
+ Mock app,
+ OutlookFolder rootFolder,
+ IEnumerable recents
+ )
+ {
+ var autoFile = new Mock();
+ autoFile.SetupGet(x => x.RecentsList).Returns(new SloLinkedList(recents));
+
+ var olObjects = new Mock();
+ olObjects.SetupGet(x => x.App).Returns(app.Object);
+ olObjects.SetupGet(x => x.ArchiveRootPath).Returns(rootFolder.FolderPath);
+ olObjects.SetupGet(x => x.Root).Returns(rootFolder);
+
+ var globals = new Mock();
+ globals.SetupGet(x => x.AF).Returns(autoFile.Object);
+ globals.SetupGet(x => x.Ol).Returns(olObjects.Object);
+ return globals;
+ }
+
+ private static Mock CreateApplication(
+ IDictionary rootFolders
+ )
+ {
+ var app = new Mock();
+ var nameSpace = new Mock();
+ nameSpace.SetupGet(x => x.Folders).Returns(CreateFoldersCollection(rootFolders).Object);
+ app.SetupGet(x => x.Session).Returns(nameSpace.Object);
+ return app;
+ }
+
+ private static Mock CreateFolder(
+ string folderPath,
+ IDictionary childFolders
+ )
+ {
+ var folder = new Mock();
+ folder.SetupGet(x => x.Name).Returns(GetLeafName(folderPath));
+ folder.SetupGet(x => x.FolderPath).Returns(folderPath);
+ folder
+ .SetupGet(x => x.Folders)
+ .Returns(
+ CreateFoldersCollection(
+ childFolders ?? new Dictionary()
+ ).Object
+ );
+ return folder;
+ }
+
+ private static Mock CreateFoldersCollection(
+ IDictionary foldersByName
+ )
+ {
+ var folders = new Mock();
+ var enumerableItems = foldersByName?.Values?.ToArray() ?? Array.Empty();
+ var collection = new ArrayList(enumerableItems);
+
+ folders
+ .Setup(x => x[It.IsAny