Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
463 changes: 463 additions & 0 deletions QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
[TestMethod]
public void ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProjection()
Expand All @@ -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")
Expand All @@ -243,12 +249,12 @@ public void ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProject
}

/// <summary>
/// Issue #678, remediation R2. The boundary case the projection previously got wrong: a
/// non-null globals whose <c>ArchiveRootPath</c> is EMPTY, with a leading-separator
/// suggestion path. <c>FolderPredictor.ProjectSuggestionPath</c> guards only on
/// <c>_globals is null</c> and then forms <c>ArchiveRootPath + "\\"</c> unconditionally, so
/// in this state its prefix is a single separator and its <c>FolderArray</c> entries ARE
/// stripped. The carried <c>PredeterminedFolder</c> must be projected the same way, or
/// Issue #678, remediation R2, re-derived against the issue #799 AC4 behaviour: a non-null
/// globals whose <c>ArchiveRootPath</c> is EMPTY, with a leading-separator suggestion path.
/// The shared projection now leaves BOTH the <c>FolderArray</c> entries and the carried
/// <c>PredeterminedFolder</c> 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
/// <c>FolderContains</c> misses and the selection falls back to the index-1 entry — the
/// exact AC12 defect the change set out to close.
///
Expand All @@ -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<IItemViewer>();
mock.SetupGet(v => v.InvokeRequired).Returns(false);
Expand All @@ -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<int>()),
Expand Down
1 change: 1 addition & 0 deletions QuickFiler.Test/QuickFiler.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
<Compile Include="Controllers\EfcSelectionGuardTests.cs" />
<Compile Include="Controllers\BreadcrumbBridgeRouterIssue439Tests.cs" />
<Compile Include="Controllers\BreadcrumbBridgeRouterIssue439Tests.Activation.cs" />
<Compile Include="Controllers\BreadcrumbBridgeRouterScoreJoinTests.cs" />
<Compile Include="Controllers\BreadcrumbBridgeRouterIssue637Tests.cs" />
<Compile Include="Viewers\BreadcrumbBridgeCoordinatorTests.cs" />
<Compile Include="Viewers\BreadcrumbBridgeCoordinatorSupersessionTests.cs" />
Expand Down
109 changes: 106 additions & 3 deletions QuickFiler/Controllers/BreadcrumbBridgeRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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&lt;IFolderHierarchyProvider&gt; 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;
Expand All @@ -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));
Expand Down Expand Up @@ -104,6 +111,7 @@ CancellationToken cancellationToken
var chains = new Dictionary<string, IReadOnlyList<FolderBreadcrumbSegment>>(
StringComparer.OrdinalIgnoreCase
);
HashSet<string>? suppressed = null;
_boundRoot = string.IsNullOrWhiteSpace(archiveRootPath)
? string.Empty
: archiveRootPath.TrimEnd('\\', '/');
Expand All @@ -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<string>(StringComparer.OrdinalIgnoreCase);
suppressed.Add(text);
}
}

IReadOnlyList<string> 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
Expand All @@ -149,6 +176,82 @@ CancellationToken cancellationToken
DeliverDocument();
}

/// <summary>
/// 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.
/// </summary>
private IEnumerable<FolderScore> WithProjectedScoreKeys(IEnumerable<FolderScore> 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<FolderScore>();
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;
}

/// <summary>
/// AC7: the presented sequence with the known-absent labels removed, filtered BEFORE row
/// construction because row ids are assigned as <c>row-&lt;index&gt;</c> over this sequence.
/// Returns the original instance when nothing was suppressed.
/// </summary>
private IReadOnlyList<string> RetainedRows(
IReadOnlyList<string> presentedRows,
HashSet<string>? suppressed
)
{
if (suppressed == null || suppressed.Count == 0)
{
return presentedRows;
}

var retained = new List<string>(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.
Expand Down
3 changes: 2 additions & 1 deletion QuickFiler/Controllers/EfcFormController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
49 changes: 16 additions & 33 deletions QuickFiler/Controllers/QfcItemController.FolderHandling.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -250,38 +250,21 @@ public void AssignFolderComboBox()
}

/// <summary>
/// #678 AC12. Projects a raw suggestion path onto the form <c>FolderPredictor.FolderArray</c>
/// stores, so a containment probe against the combo box can match: strip
/// <paramref name="archiveRootPath"/> plus a trailing separator from the front of
/// <paramref name="folderPath"/>, case-insensitively, but only when the remainder is
/// non-empty. #678 R2: the projection mirrors <c>FolderPredictor.ProjectSuggestionPath</c>
/// for every non-null <paramref name="folderPath"/> and non-null
/// <paramref name="archiveRootPath"/>. A NULL <paramref name="archiveRootPath"/> stands for
/// that member's <c>_globals is null</c> 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
/// <paramref name="folderPath"/> is returned unchanged rather than dereferenced;
/// <c>ProjectSuggestionPath</c> does not guard it because its input comes from
/// <c>Suggestions</c>. Second, a non-null globals with a null <c>Ol</c> 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
/// <c>FolderPredictor.FolderArray</c> stores, so a containment probe against the combo box
/// can match. This member and <c>FolderPredictor.ProjectSuggestionPath</c> now share ONE
/// projection, <c>ArchiveStemProjection.ToDisplayStem</c>, 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
/// <paramref name="archiveRootPath"/> is now the identity projection, as is a null or empty
/// <paramref name="folderPath"/> and any path that is not strictly under the root.
/// </summary>
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
);
}

/// <summary>
Expand Down
33 changes: 0 additions & 33 deletions QuickFiler/Controllers/QfcItemController.ViewerSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/// <summary>Configures the lazy popup with the existing environment and active theme.</summary>
internal void ConfigureBreadcrumbDropDown(
ItemViewer viewer,
Expand Down
Loading
Loading