diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs new file mode 100644 index 000000000..30f654201 --- /dev/null +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs @@ -0,0 +1,211 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.OutlookObjects.Folder; +using F = QuickFiler.Test.Controllers.EfcDescendantRouterFixture; + +namespace QuickFiler.Test.Controllers +{ + /// + /// Issue #1007 AC9 and AC5 (S10, D-Q13, D-Q18, D-Q23): a segment double-click on the + /// three-segment row Clients > Acme > Beta (below the bound archive root) + /// activates, commits and shortens the row at Acme; the [+] or Right then lists every child of + /// Acme instead of restoring Beta, and the next Right moves to the first child. + /// + public sealed partial class BreadcrumbBridgeRouterIssue1007Tests + { + private const string Acme = F.Root + @"\Clients\Acme"; + private const string Beta = Acme + @"\Beta"; + + /// AC9: the double-click selects the segment's folder and shortens the row there. + [TestMethod] + public async Task SegmentDoubleClick_ActivatesCommitsAndShortensAtomically() + { + // Arrange + var fixture = await ThreeSegmentAsync(); + await SelectAsync(fixture, "row-0"); + + // Act + await DoubleClickAsync(fixture, 1); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\Acme"); + string row = fixture.LatestRender(); + row.Should().Contain("data-role=\"reexpand\""); + row.Should().NotContain("data-segment-index=\"2\"", "Beta is hidden"); + } + + /// AC9: the [+] on the shortened row lists Acme's children; Beta stays hidden. + [TestMethod] + public async Task LeafExpandToggle_OnShortenedRow_ListsSegmentChildren() + { + // Arrange + var fixture = await ThreeSegmentAsync(); + await DoubleClickAsync(fixture, 1); + + // Act + await ExpandAsync(fixture, "row-0"); + + // Assert + VerifyFetched(fixture, Acme, Times.Once()); + string row = fixture.LatestRender(); + row.Should().Contain(">Alpha"); + row.Should().NotContain("data-segment-index=\"2\""); + } + + /// AC5: Right on the shortened row lists the segment's children with one query. + [TestMethod] + public async Task ArrowRight_OnShortenedRow_ListsSegmentChildrenWithSingleQuery() + { + // Arrange + var fixture = await ThreeSegmentAsync(); + await DoubleClickAsync(fixture, 1); + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + VerifyFetched(fixture, Acme, Times.Once()); + fixture.LatestRender().Should().Contain(">Alpha"); + fixture.LatestRender().Should().NotContain("data-segment-index=\"2\""); + } + + /// AC9 (D-Q18): the highlight stays on Acme, and the next Right selects its first child. + [TestMethod] + public async Task ShortenedRowExpanded_NextRight_SelectsFirstChild() + { + // Arrange + var fixture = await ThreeSegmentAsync(); + await DoubleClickAsync(fixture, 1); + await KeyAsync(fixture, "row-0", "Right"); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\Acme"); + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\Acme\Alpha"); + } + + /// AC9: while the shortened row is expanded, its terminal carries the collapse affordance. + [TestMethod] + public async Task ShortenedRowExpanded_RendersCollapseAffordanceAtTerminal() + { + // Arrange + var fixture = await ThreeSegmentAsync(); + await DoubleClickAsync(fixture, 1); + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + fixture.LatestRender().Should().Contain("data-role=\"reexpand\">−"); + } + + /// AC1, AC9: Up from Acme's first child onto the shortened row entry selects Acme again. + [TestMethod] + public async Task ArrowUp_OntoShortenedRowEntry_SelectsActiveSegmentFolder() + { + // Arrange: the row is shortened at Acme and its first child Alpha is selected. + var fixture = await ThreeSegmentAsync(); + await DoubleClickAsync(fixture, 1); + await KeyAsync(fixture, "row-0", "Right"); + await KeyAsync(fixture, "row-0", "Right"); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\Acme\Alpha"); + + // Act + await KeyAsync(fixture, "row-0", "Up"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\Acme"); + } + + /// + /// R3: a click on Clients after the double-click at Acme leaves the row collapsed away from + /// its active segment; Right then restores the full chain instead of listing children. + /// + [TestMethod] + public async Task ArrowRight_OnRowCollapsedAwayFromActiveSegment_RestoresChain() + { + // Arrange + var fixture = await ThreeSegmentAsync(); + await DoubleClickAsync(fixture, 1); + await fixture.Router.ProcessInboundAsync( + "{\"type\":\"segmentActivate\",\"rowId\":\"row-0\",\"segmentIndex\":0}" + ); + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + fixture + .LatestRender() + .Should() + .Contain("data-segment-index=\"2\"", "Beta is shown again"); + VerifyFetched(fixture, Acme, Times.Never()); + } + + /// + /// Issue #1007 R3, N-1: the [+] (leafExpandToggle) on a row collapsed away from its + /// active segment restores the full chain without listing children, like Right does. + /// + [TestMethod] + public async Task LeafExpandToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChain() + { + // Arrange: the double-click at Acme shortens the row; activating Clients moves the + // active segment away from the collapse point. + var fixture = await ThreeSegmentAsync(); + await DoubleClickAsync(fixture, 1); + await fixture.Router.ProcessInboundAsync( + "{\"type\":\"segmentActivate\",\"rowId\":\"row-0\",\"segmentIndex\":0}" + ); + + // Act + await ExpandAsync(fixture, "row-0"); + + // Assert + fixture + .LatestRender() + .Should() + .Contain("data-segment-index=\"2\"", "Beta is shown again"); + VerifyFetched(fixture, Acme, Times.Never()); + } + + // Binds Clients\Acme\Beta with the chain Clients > Acme > Beta and Acme's children. + private static async Task ThreeSegmentAsync() + { + var fixture = new EfcDescendantRouterFixture(); + fixture.Children[Acme] = new[] + { + F.Segment(Acme + @"\Alpha", "Alpha", false), + F.Segment(Beta, "Beta", false), + }; + IReadOnlyList chain = new[] + { + F.Segment(F.Root + @"\Clients", "Clients", true), + F.Segment(Acme, "Acme", true), + F.Segment(Beta, "Beta", false), + }; + fixture + .Provider.Setup(p => + p.GetAncestorChainAsync( + It.Is(k => k.FolderPath == Beta), + It.IsAny() + ) + ) + .ReturnsAsync(chain); + await fixture.BindAsync(@"Clients\Acme\Beta"); + return fixture; + } + + private static Task DoubleClickAsync(EfcDescendantRouterFixture fixture, int index) => + fixture.Router.ProcessInboundAsync( + "{\"type\":\"segmentDoubleClick\",\"rowId\":\"row-0\",\"segmentIndex\":" + + index + + "}" + ); + } +} diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.Navigation.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.Navigation.cs new file mode 100644 index 000000000..0483b26ff --- /dev/null +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.Navigation.cs @@ -0,0 +1,326 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Test.Controllers +{ + /// + /// Issue #1007 AC1, AC2, AC3, AC11: Up, Down, Home, End, PageUp and PageDown walk the visible + /// order (rows plus shown subfolders) and select each entry as a click does. + /// + public sealed partial class BreadcrumbBridgeRouterIssue1007Tests + { + private const string North = @"Clients\North"; + private const string South = @"Clients\South"; + + /// AC1: Down from an expanded row selects its first visible subfolder. + [TestMethod] + public async Task ArrowDown_FromExpandedRow_SelectsFirstVisibleDescendant() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + await ExpandAsync(fixture, "row-0"); + + // Act + await KeyAsync(fixture, "row-0", "Down"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + SelectedNodeTitle(fixture).Should().Be(EfcDescendantRouterFixture.Q3); + } + + /// AC1: Down from a selected subfolder selects the next visible entry. + [TestMethod] + public async Task ArrowDown_FromSelectedDescendant_SelectsNextVisibleEntry() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + await ExpandAsync(fixture, "row-0"); + await fixture.ActivateAsync("row-0", fixture.NodeId(EfcDescendantRouterFixture.Q3)); + + // Act + await KeyAsync(fixture, "row-0", "Down"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q4"); + } + + /// AC1: the children of a collapsed subfolder are skipped. + [TestMethod] + public async Task ArrowDown_SkipsChildrenOfCollapsedNode() + { + // Arrange: Q3 is expanded, then collapsed again, then selected. + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + await ExpandAsync(fixture, "row-0"); + string q3 = fixture.NodeId(EfcDescendantRouterFixture.Q3); + await fixture.ToggleAsync("row-0", q3); + await fixture.ToggleAsync("row-0", q3); + await fixture.ActivateAsync("row-0", q3); + + // Act + await KeyAsync(fixture, "row-0", "Down"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q4"); + } + + /// AC1: Up from the row below an expanded block lands on its last visible subfolder. + [TestMethod] + public async Task ArrowUp_FromRowBelowExpandedBlock_SelectsLastVisibleDescendant() + { + // Arrange: row-0 shows Q3 (expanded: Invoices, Receipts) and Q4. + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + await fixture.ToggleAsync("row-0", fixture.NodeId(EfcDescendantRouterFixture.Q3)); + await SelectAsync(fixture, "row-1"); + + // Act + await KeyAsync(fixture, "row-1", "Up"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q4"); + } + + /// AC3: an arrow onto a subfolder commits its archive-relative path and notifies. + [TestMethod] + public async Task ArrowDown_OntoDescendant_CommitsArchiveRelativePathAndRaisesChanged() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + await ExpandAsync(fixture, "row-0"); + int before = fixture.SelectionEvents.Count; + + // Act + await KeyAsync(fixture, "row-0", "Down"); + + // Assert + fixture.SelectionEvents.Skip(before).Should().Equal(@"Clients\North\Q3"); + } + + /// AC3: Enter after moving onto a subfolder files to that subfolder. + [TestMethod] + public async Task ArrowDown_OntoDescendant_ThenRowConfirmed_RaisesConfirmationForDescendant() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + await ExpandAsync(fixture, "row-0"); + await KeyAsync(fixture, "row-0", "Down"); + + // Act + await fixture.InboundAsync("rowConfirmed", "row-0"); + + // Assert + fixture.Confirmations.Should().Be(1); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + } + + /// AC11: Home selects the first entry and never moves focus to the search box. + [TestMethod] + public async Task Home_SelectsFirstEntryWithoutFocusSearch() + { + // Arrange + var fixture = await BoundAsync(North, South); + int focusRequests = 0; + fixture.Router.FocusSearchRequested += (sender, args) => focusRequests++; + await SelectAsync(fixture, "row-1"); + + // Act + await KeyAsync(fixture, "row-1", "Home"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(North); + focusRequests.Should().Be(0); + fixture.Posts.Should().NotContain(post => post.Contains("focusSearch")); + } + + /// AC11: End selects the last visible entry, a subfolder of the last row. + [TestMethod] + public async Task End_SelectsLastVisibleDescendant() + { + // Arrange + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-1"); + await SelectAsync(fixture, "row-0"); + + // Act + await KeyAsync(fixture, "row-0", "End"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\South\S1"); + } + + /// AC11: PageDown moves by the page size and stops at the last entry. + [TestMethod] + public async Task PageDown_MovesByPageSizeAndClamps() + { + // Arrange: visible order row-0, Q3, Q4, row-1. + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + await ExpandAsync(fixture, "row-0"); + + // Act / Assert + await KeyAsync(fixture, "row-0", "PageDown", "2"); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q4"); + await KeyAsync(fixture, "row-0", "PageDown", "2"); + fixture.Router.SelectedFolderPath.Should().Be(South); + } + + /// AC11: PageUp moves by the page size and stops at the first entry. + [TestMethod] + public async Task PageUp_MovesByPageSizeAndClamps() + { + // Arrange: visible order row-0, Q3, Q4, row-1. + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + await SelectAsync(fixture, "row-1"); + + // Act / Assert + await KeyAsync(fixture, "row-1", "PageUp", "2"); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + await KeyAsync(fixture, "row-0", "PageUp", "2"); + fixture.Router.SelectedFolderPath.Should().Be(North); + } + + /// Error: a non-integer page size is rejected by the codec before routing. + [TestMethod] + public async Task PageDown_NonIntegerPageSize_IsRejectedByCodec() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + + // Act + Func act = () => KeyAsync(fixture, "row-0", "PageDown", "\"two\""); + + // Assert + await act.Should().ThrowAsync(); + fixture.Router.SelectedFolderPath.Should().Be(North); + } + + /// AC11 (D-Q26): with nothing selected, Down and Home select the first row; Up and End the last. + [TestMethod] + public async Task NoSelection_DownAndHomeSelectFirst_UpAndEndSelectLast() + { + // Arrange + var expected = new[] + { + ("Down", North), + ("Home", North), + ("Up", South), + ("End", South), + }; + + foreach ((string key, string path) in expected) + { + var fixture = await BoundAsync(North, South); + + // Act + await KeyAsync(fixture, string.Empty, key); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(path, $"{key} with no selection"); + } + } + + /// Guard (AC2): Down on the last visible entry posts nothing. + [TestMethod] + public async Task ArrowDown_OnLastVisibleEntry_PostsNothing() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-1"); + int before = fixture.Posts.Count; + + // Act + await KeyAsync(fixture, "row-1", "Down"); + + // Assert + fixture.Posts.Count.Should().Be(before); + fixture.Router.SelectedFolderPath.Should().Be(South); + } + + /// Guard (AC2): Up on the first entry moves focus to the search box. + [TestMethod] + public async Task ArrowUp_OnFirstEntry_RaisesFocusSearchRequested() + { + // Arrange + var fixture = await BoundAsync(North, South); + int focusRequests = 0; + fixture.Router.FocusSearchRequested += (sender, args) => focusRequests++; + await SelectAsync(fixture, "row-0"); + + // Act + await KeyAsync(fixture, "row-0", "Up"); + + // Assert + focusRequests.Should().Be(1); + fixture.Posts.Last().Should().Contain("focusSearch"); + } + + /// Guard (S3): a missing, zero or negative page size is a logged no-op. + [TestMethod] + public async Task PageKey_InvalidPageSize_IsLoggedNoOp() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + int before = fixture.Posts.Count; + + // Act + await KeyAsync(fixture, "row-0", "PageDown"); + await KeyAsync(fixture, "row-0", "PageDown", "0"); + await KeyAsync(fixture, "row-0", "PageUp", "-1"); + + // Assert + fixture.Posts.Count.Should().Be(before); + fixture.Router.SelectedFolderPath.Should().Be(North); + } + + private static async Task BoundAsync(params string[] rows) + { + var fixture = new EfcDescendantRouterFixture(); + await fixture.BindAsync(rows); + return fixture; + } + + private static Task SelectAsync(EfcDescendantRouterFixture fixture, string rowId) => + fixture.InboundAsync("rowSelected", rowId); + + private static Task ExpandAsync(EfcDescendantRouterFixture fixture, string rowId) => + fixture.InboundAsync("leafExpandToggle", rowId); + + private static Task KeyAsync( + EfcDescendantRouterFixture fixture, + string rowId, + string key, + string pageSizeJson = null + ) => + fixture.Router.ProcessInboundAsync( + "{\"type\":\"arrowKey\",\"rowId\":\"" + + rowId + + "\",\"key\":\"" + + key + + "\"" + + (pageSizeJson == null ? string.Empty : ",\"pageSize\":" + pageSizeJson) + + "}" + ); + + // The title of the node marked aria-selected in the latest render, or null. + private static string SelectedNodeTitle(EfcDescendantRouterFixture fixture) + { + var match = System.Text.RegularExpressions.Regex.Match( + fixture.LatestRender(), + "title=\"([^\"]+)\" aria-selected=\"true\"" + ); + return match.Success ? System.Net.WebUtility.HtmlDecode(match.Groups[1].Value) : null; + } + } +} diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.cs new file mode 100644 index 000000000..fcb92d843 --- /dev/null +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.cs @@ -0,0 +1,332 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.OutlookObjects.Folder; +using F = QuickFiler.Test.Controllers.EfcDescendantRouterFixture; + +namespace QuickFiler.Test.Controllers +{ + /// + /// Issue #1007 EFC keyboard navigation through the real BreadcrumbBridgeRouter over the + /// strict-mock : vertical keys (Navigation partial), + /// horizontal keys (this part) and segment double-click (DoubleClick partial). Node identities + /// are read from the rendered data-node-id attributes, never asserted as literals. + /// + [TestClass] + public sealed partial class BreadcrumbBridgeRouterIssue1007Tests + { + /// AC5: Right on an expanded row selects its first descendant and highlights it. + [TestMethod] + public async Task ArrowRight_OnExpandedRow_SelectsFirstDescendantWithNodeId() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + await ExpandAsync(fixture, "row-0"); + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + SelectedNodeTitle(fixture).Should().Be(F.Q3); + } + + /// AC5: Right on a selected subfolder that has children expands it. + [TestMethod] + public async Task ArrowRight_OnUnexpandedNodeWithChildren_ExpandsNode() + { + // Arrange + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + await fixture.ActivateAsync("row-0", fixture.NodeId(F.Q3)); + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + VerifyFetched(fixture, F.Q3, Times.Once()); + fixture.LatestRender().Should().Contain(">Invoices"); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + } + + /// AC5: Right on an expanded subfolder moves to its first child. + [TestMethod] + public async Task ArrowRight_OnExpandedNode_SelectsFirstChildNode() + { + // Arrange + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + string q3 = fixture.NodeId(F.Q3); + await fixture.ToggleAsync("row-0", q3); + await fixture.ActivateAsync("row-0", q3); + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3\Invoices"); + } + + /// S7: a second Right during the row fetch issues no query and the row ends expanded. + [TestMethod] + public async Task ArrowRight_ReentrantDuringFetch_EndsExpandedWithOneQuery() + { + // Arrange: hold the North fetch open. + var fixture = await BoundAsync(North, South); + var pending = Hold(fixture, F.North); + await SelectAsync(fixture, "row-0"); + + // Act: the second Right arrives while the first fetch is in flight. Neither key is + // awaited before the fetch is released, so a second fetch cannot block the test. + Task first = KeyAsync(fixture, "row-0", "Right"); + Task second = KeyAsync(fixture, "row-0", "Right"); + pending.SetResult(fixture.Children[F.North]); + await Task.WhenAll(first, second); + + // Assert + VerifyFetched(fixture, F.North, Times.Once()); + fixture.LatestRender().Should().Contain(">Q3", "the row ends expanded"); + } + + /// AC7: Left on an expanded row collapses it and keeps the row selected. + [TestMethod] + public async Task ArrowLeft_OnExpandedRow_CollapsesAndKeepsSelection() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + await ExpandAsync(fixture, "row-0"); + + // Act + await KeyAsync(fixture, "row-0", "Left"); + + // Assert: collapsed, still on North; Right re-expands North, not a parent segment. + fixture.LatestRender().Should().NotContain(">Q3"); + fixture.Router.SelectedFolderPath.Should().Be(North); + await KeyAsync(fixture, "row-0", "Right"); + fixture.LatestRender().Should().Contain(">Q3"); + } + + /// AC7: Left on an expanded subfolder collapses only that subfolder. + [TestMethod] + public async Task ArrowLeft_OnExpandedNode_CollapsesOnlyThatNode() + { + // Arrange + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + string q3 = fixture.NodeId(F.Q3); + await fixture.ToggleAsync("row-0", q3); + await fixture.ActivateAsync("row-0", q3); + + // Act + await KeyAsync(fixture, "row-0", "Left"); + + // Assert + string html = fixture.LatestRender(); + html.Should().NotContain(">Invoices"); + html.Should().Contain(">Q4", "the row stays expanded"); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + } + + /// AC7: Left on a non-expanded row does nothing (no parent walk, no hiding). + [TestMethod] + public async Task ArrowLeft_OnNonExpandedRow_IsNoOp() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + int before = fixture.Posts.Count; + + // Act + await KeyAsync(fixture, "row-0", "Left"); + + // Assert + fixture.Posts.Count.Should().Be(before); + fixture.Router.SelectedFolderPath.Should().Be(North); + } + + /// AC7: Left on a collapsed subfolder does nothing. + [TestMethod] + public async Task ArrowLeft_OnCollapsedNode_IsNoOp() + { + // Arrange + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + await fixture.ActivateAsync("row-0", fixture.NodeId(F.Q3)); + int before = fixture.Posts.Count; + + // Act + await KeyAsync(fixture, "row-0", "Left"); + + // Assert + fixture.Posts.Count.Should().Be(before); + } + + /// AC7 (S1): a collapse that hides the selection re-commits the nearest visible ancestor. + [TestMethod] + public async Task CollapseHidingSelection_RecommitsNearestVisibleAncestor() + { + // Arrange: Invoices (under Q3) is selected. + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + string q3 = fixture.NodeId(F.Q3); + await fixture.ToggleAsync("row-0", q3); + await fixture.ActivateAsync("row-0", fixture.NodeId(F.Invoices)); + + // Act / Assert: collapsing Q3 moves the selection to Q3. + await fixture.ToggleAsync("row-0", q3); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + SelectedNodeTitle(fixture).Should().Be(F.Q3); + + // Act / Assert: collapsing the row hides Q3, so the row itself is re-committed. + await ExpandAsync(fixture, "row-0"); + fixture.Router.SelectedFolderPath.Should().Be(North); + } + + /// AC8: Ctrl+Left on a row activates the parent segment, commits it and shortens. + [TestMethod] + public async Task ParentLeft_OnRow_ActivatesParentSegmentCommitsAndShortens() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + + // Act + await KeyAsync(fixture, "row-0", "ParentLeft"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be("Clients"); + string row = fixture.LatestRender(); + row.Should().Contain("data-role=\"reexpand\""); + row.Should().NotContain(">North"); + } + + /// AC8: Ctrl+Left on a subfolder selects its parent and leaves the parent expanded. + [TestMethod] + public async Task ParentLeft_OnNode_SelectsParentKeepingExpansion() + { + // Arrange + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + await fixture.ToggleAsync("row-0", fixture.NodeId(F.Q3)); + await fixture.ActivateAsync("row-0", fixture.NodeId(F.Invoices)); + + // Act + await KeyAsync(fixture, "row-0", "ParentLeft"); + + // Assert + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + fixture.LatestRender().Should().Contain(">Invoices"); + } + + /// Guard (AC5): Right on a non-expanded row with children expands it. + [TestMethod] + public async Task ArrowRight_OnUnexpandedRowWithChildren_Expands() + { + // Arrange + var fixture = await BoundAsync(North, South); + await SelectAsync(fixture, "row-0"); + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + VerifyFetched(fixture, F.North, Times.Once()); + fixture.LatestRender().Should().Contain(">Q3"); + } + + /// Guard (decision table): Right on an expanded subfolder whose fetch is in flight does nothing. + [TestMethod] + public async Task ArrowRight_OnExpandedNodeWithFetchInFlight_DoesNothing() + { + // Arrange: Q3 is selected and its expansion fetch is held open. + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + string q3 = fixture.NodeId(F.Q3); + await fixture.ActivateAsync("row-0", q3); + var pending = Hold(fixture, F.Q3); + Task toggle = fixture.ToggleAsync("row-0", q3); + + // Act: the key is not awaited before the fetch is released. + Task right = KeyAsync(fixture, "row-0", "Right"); + pending.SetResult(fixture.Children[F.Q3]); + await Task.WhenAll(toggle, right); + + // Assert + VerifyFetched(fixture, F.Q3, Times.Once()); + fixture.Router.SelectedFolderPath.Should().Be(@"Clients\North\Q3"); + } + + /// Guard (AC6): Right on a subfolder without children posts nothing. + [TestMethod] + public async Task ArrowRight_OnLeaf_PostsNothing() + { + // Arrange + var fixture = await BoundAsync(North, South); + await ExpandAsync(fixture, "row-0"); + await fixture.ActivateAsync("row-0", fixture.NodeId(F.Q4)); + int before = fixture.Posts.Count; + + // Act + await KeyAsync(fixture, "row-0", "Right"); + + // Assert + fixture.Posts.Count.Should().Be(before); + } + + /// Guard (AC8): Ctrl+Left with the first segment active does nothing. + [TestMethod] + public async Task ParentLeft_AtFirstSegment_IsNoOp() + { + // Arrange: activate segment 0 (Clients). + var fixture = await BoundAsync(North, South); + await fixture.Router.ProcessInboundAsync( + "{\"type\":\"segmentActivate\",\"rowId\":\"row-0\",\"segmentIndex\":0}" + ); + int before = fixture.Posts.Count; + + // Act + await KeyAsync(fixture, "row-0", "ParentLeft"); + + // Assert + fixture.Posts.Count.Should().Be(before); + fixture.Router.SelectedFolderPath.Should().Be("Clients"); + } + + // Holds the provider fetch for one folder on a TaskCompletionSource (no real wait). + private static TaskCompletionSource> Hold( + EfcDescendantRouterFixture fixture, + string path + ) + { + var pending = new TaskCompletionSource>(); + fixture + .Provider.Setup(p => + p.GetImmediateSubfoldersAsync( + It.Is(k => k.FolderPath == path), + It.IsAny() + ) + ) + .Returns(pending.Task); + return pending; + } + + private static void VerifyFetched( + EfcDescendantRouterFixture fixture, + string path, + Times times + ) => + fixture.Provider.Verify( + p => + p.GetImmediateSubfoldersAsync( + It.Is(k => k.FolderPath == path), + It.IsAny() + ), + times + ); + } +} diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs index d71dc7357..0aa89bea3 100644 --- a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs @@ -134,7 +134,8 @@ public void SegmentDoubleClick_NegativeIndex_ViaHostEvent_DoesNotThrowAndLeavesS /// /// #498 / AC-3 valid-index clause: the range guard is a rejection of invalid input only. A /// valid non-leaf segmentIndex delivered through the same host-event seam still - /// collapses the row and posts exactly one row-scoped render payload. + /// collapses the row; #1007 S10 also commits the segment, so the assertion reads the last + /// render, which is the row-scoped render. /// [TestMethod] public void SegmentDoubleClick_ValidIndex_ViaHostEvent_CollapsesRowAndPostsRender() @@ -151,11 +152,11 @@ public void SegmentDoubleClick_ValidIndex_ViaHostEvent_CollapsesRowAndPostsRende "{\"type\":\"segmentDoubleClick\",\"rowId\":\"row-0\",\"segmentIndex\":0}" ); - // Assert: one new outbound payload, and it is the row-scoped render for row-0. + // Assert: at least one new payload, and the last render is row-scoped for row-0. _posted .Count.Should() - .Be(postedBefore + 1, "a valid segmentIndex still produces a render post"); - string render = _posted.Single(p => p.Contains("\"type\":\"render\"")); + .BeGreaterThan(postedBefore, "a valid segmentIndex still produces a render post"); + string render = _posted.Last(p => p.Contains("\"type\":\"render\"")); render.Should().Contain("\"rowId\":\"row-0\""); } diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs index 366c86dca..d6c07da1c 100644 --- a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs @@ -154,26 +154,31 @@ public void ArrowKeyUp_OnNonTopRow_SelectsPreviousRowWithoutFocusSearch() _posted.Should().NotContain(p => p.Contains("\"type\":\"focusSearch\"")); } + /// + /// #1007 AC9 (D-Q23): after a double-click shortens the row at Inbox, the [+] lists Inbox's + /// children through one provider query keyed on that segment, and the hidden trailing + /// segment stays hidden (the former restore of the full breadcrumb is removed). + /// [TestMethod] public void LeafExpandToggle_OnCollapsedRow_ReExpandsWithoutProviderQuery() { - // Arrange: collapse the row first. + // Arrange: shorten the row at segment 0 first. Bind(); Inbound("{\"type\":\"segmentDoubleClick\",\"rowId\":\"row-1\",\"segmentIndex\":0}"); - // Act: the affordance now re-expands the full breadcrumb. + // Act: the affordance lists the shortened segment's children. Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-1\"}"); // Assert _provider.Verify( p => p.GetImmediateSubfoldersAsync( - It.IsAny(), + It.Is(k => k.FolderPath == "Inbox"), It.IsAny() ), - Times.Never + Times.Once ); - _posted.Last(p => p.Contains("\"type\":\"render\"")).Should().Contain(">Alpha<"); + _posted.Last(p => p.Contains("\"type\":\"render\"")).Should().NotContain(">Alpha<"); } [TestMethod] @@ -201,6 +206,10 @@ public void LeafExpandToggle_OnExpandedLeaf_CollapsesWithoutSecondQuery() .Contain(JsonEscaped("data-role=\"leaf\">+")); } + /// + /// #1007 AC5, AC9 (D-Q23): Right on a row shortened at Inbox lists Inbox's children through + /// one provider query and keeps the trailing segment hidden. + /// [TestMethod] public void ArrowKeyRight_WhenCollapsed_ReExpandsWithoutProviderQuery() { @@ -215,14 +224,18 @@ public void ArrowKeyRight_WhenCollapsed_ReExpandsWithoutProviderQuery() _provider.Verify( p => p.GetImmediateSubfoldersAsync( - It.IsAny(), + It.Is(k => k.FolderPath == "Inbox"), It.IsAny() ), - Times.Never + Times.Once ); - _posted.Last(p => p.Contains("\"type\":\"render\"")).Should().Contain(">Alpha<"); + _posted.Last(p => p.Contains("\"type\":\"render\"")).Should().NotContain(">Alpha<"); } + /// + /// An unknown arrow key is a logged no-op. #1007 AC11 makes Home a supported key, so the + /// test uses Insert, which stays unknown. + /// [TestMethod] public void ArrowKey_UnknownKey_IsLoggedNoOp() { @@ -231,7 +244,7 @@ public void ArrowKey_UnknownKey_IsLoggedNoOp() int postedBefore = _posted.Count; // Act - Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Home\"}"); + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Insert\"}"); // Assert: no state change and no outbound payloads. _posted.Count.Should().Be(postedBefore); diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs index 8ee40f426..34fd72c69 100644 --- a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs @@ -138,6 +138,7 @@ public void BindRowsAsync_WithInitializedHost_DeliversGeneratedDocument() _navigated[0].Should().Contain("90%"); } + /// #1007 S10: the double-click also commits the segment, so the last render is read. [TestMethod] public void SegmentDoubleClick_OnNonLeafSegment_CollapsesAndReRenders() { @@ -147,8 +148,8 @@ public void SegmentDoubleClick_OnNonLeafSegment_CollapsesAndReRenders() // Act: double-click the root segment of the suggestion row (row-1). Inbound("{\"type\":\"segmentDoubleClick\",\"rowId\":\"row-1\",\"segmentIndex\":0}"); - // Assert: a row-scoped render fragment with the re-expand affordance was posted. - string render = _posted.Single(p => p.Contains("\"type\":\"render\"")); + // Assert: the last render is row-scoped and carries the re-expand affordance. + string render = _posted.Last(p => p.Contains("\"type\":\"render\"")); render.Should().Contain("\"rowId\":\"row-1\""); render.Should().Contain(JsonEscaped("data-role=\"reexpand\"")); render.Should().NotContain(">Alpha<"); @@ -198,83 +199,69 @@ public void ArrowKeyRight_ThenLeft_ExpandsAndCollapses() } /// - /// #440 Efc Left: on a multi-segment row the Left arrow attempts the tree transition - /// first, moving the active segment exactly one step toward the root. The subsequent - /// expansion is keyed on the parent segment, which is only true when the active index - /// moved from the leaf to the segment immediately below it. + /// #1007 AC7: Left never selects a parent. On a non-expanded row it posts no render, and + /// the following toggle still expands the leaf, so the active segment did not move. /// [TestMethod] - public void HandleArrowKey_LeftOnMultiSegmentRow_ActivatesParentSegment() + public void HandleArrowKey_LeftOnNonExpandedMultiSegmentRow_IsNoOpAndKeepsLeafActive() { // Arrange: row-1 carries Inbox -> Projects -> Alpha with the leaf active after bind. Bind(); _provider.Invocations.Clear(); + int postedBefore = _posted.Count; // Act Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Left\"}"); + _posted.Count.Should().Be(postedBefore, "Left on a non-expanded row is a no-op"); Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-1\"}"); // Assert - _provider.Verify( - p => - p.GetImmediateSubfoldersAsync( - It.Is(k => k.FolderPath == "Inbox\\Projects"), - It.IsAny() - ), - Times.Once - ); _provider.Verify( p => p.GetImmediateSubfoldersAsync( It.Is(k => k.FolderPath == LeafPath), It.IsAny() ), - Times.Never + Times.Once ); } /// - /// #440 Efc Left: repeated Left presses walk the active segment to the root, and the - /// press that ActivateSegment refuses falls through (decision D1) to the pre-existing - /// row.LeftArrow() collapse behavior. + /// #1007 AC7: the #440 Left walk and the Left segment hiding are removed, so repeated + /// Left presses on a selected non-expanded row post no row render and keep the selection. /// [TestMethod] - public void HandleArrowKey_RepeatedLeft_WalksToRootThenFallsThroughToExistingBehavior() + public void HandleArrowKey_RepeatedLeftOnNonExpandedRow_PostsNoRenderAndKeepsSelection() { // Arrange Bind(); + Inbound("{\"type\":\"rowSelected\",\"rowId\":\"row-1\"}"); + string selected = _router.SelectedFolderPath; - // Act: two presses walk 2 -> 1 -> 0; the third is refused and falls through. + // Act Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Left\"}"); Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Left\"}"); Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Left\"}"); - // Assert: only the third render carries the collapse (re-expand) affordance. - List renders = _posted + // Assert + _posted .Where(p => p.Contains("\"type\":\"render\"") && p.Contains("\"rowId\":\"row-1\"")) - .ToList(); - renders.Should().HaveCount(3); - renders[0].Should().NotContain(JsonEscaped("data-role=\"reexpand\"")); - renders[1].Should().NotContain(JsonEscaped("data-role=\"reexpand\"")); - renders[2].Should().Contain(JsonEscaped("data-role=\"reexpand\"")); - renders[2].Should().NotContain(">Alpha<"); + .Should() + .BeEmpty(); + _router.SelectedFolderPath.Should().Be(selected); } /// - /// #440 Efc Right on a COLLAPSED row whose activated segment is a non-leaf parent: the - /// tree transition is attempted before row.ReExpand(), so exactly one - /// GetImmediateSubfoldersAsync call keyed on the active segment is issued and no - /// ResolveLeafKeyAsync call is added on the expansion path. + /// #1007 AC9: a double-click activates and shortens the row at Projects; Right then lists + /// the active segment's children through exactly one GetImmediateSubfoldersAsync call + /// keyed on that segment, and no ResolveLeafKeyAsync call is added on the expansion path. /// [TestMethod] public void HandleArrowKey_RightOnActivatedParent_ExpandsViaSingleImmediateSubfolderCall() { - // Arrange: collapse after segment 1, then activate segment 1. ActivateSegment leaves - // CollapsedAfterIndex untouched, so the row is still collapsed when Right arrives. + // Arrange: the double-click activates segment 1 and shortens the row there. Bind(); - _provider.Invocations.Clear(); Inbound("{\"type\":\"segmentDoubleClick\",\"rowId\":\"row-1\",\"segmentIndex\":1}"); - Inbound("{\"type\":\"segmentActivate\",\"rowId\":\"row-1\",\"segmentIndex\":1}"); _provider.Invocations.Clear(); // Act @@ -296,24 +283,24 @@ public void HandleArrowKey_RightOnActivatedParent_ExpandsViaSingleImmediateSubfo } /// - /// #440 Efc Right after the active segment is already expanded: the descent mechanism - /// chosen by decision D9 selects child index 0 through BreadcrumbRow.GetActiveChild(0). - /// When GetActiveChild(0) returns null the descent is unavailable and the decision-D1 - /// fall-through runs, leaving the selection unchanged. + /// #1007 AC5, AC14: Right on an expanded row selects its first descendant with that + /// node's identity (the node is highlighted). When the expansion attached no child, the + /// row has no children, so Right leaves the selection unchanged. /// [TestMethod] - public void HandleArrowKey_RightAfterExpansion_DescendsByChildActivation() + public void HandleArrowKey_RightOnExpandedRow_SelectsFirstDescendantUnlessNoChildren() { // Arrange: activate the non-leaf parent, then expand it. Bind(); Inbound("{\"type\":\"segmentActivate\",\"rowId\":\"row-1\",\"segmentIndex\":1}"); Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Right\"}"); - // Act: the next Right descends to child index 0. + // Act: the next Right selects the first descendant. Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Right\"}"); // Assert _router.SelectedFolderPath.Should().Be("Inbox\\Projects\\Alpha\\Kid"); + _posted.Last().Should().Contain(JsonEscaped("aria-selected=\"true\"")); // Arrange: same gesture where the active segment expands to no children at all. _provider @@ -331,7 +318,7 @@ public void HandleArrowKey_RightAfterExpansion_DescendsByChildActivation() // Act Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Right\"}"); - // Assert: GetActiveChild(0) returned null, so the selection is unchanged. + // Assert: the zero-children expansion leaves no child to move to. _router.SelectedFolderPath.Should().Be("Inbox\\Projects"); } @@ -368,9 +355,8 @@ public void ArrowKey_SingleSegmentRow_TakesPreExistingCollapsePath() } /// - /// #440 decision D2 (Efc boundaries): Left at the root and Right on a childless active - /// node remain silent no-ops. The childless early return in ExpandLeafAsync now tests the - /// ACTIVE segment rather than the leaf, and ActivateSegment's root refusal is unchanged. + /// #1007 AC7, AC6: Right on a childless active node and every Left on a non-expanded row + /// are silent no-ops (the #440 walk and the Left segment hiding are removed). /// [TestMethod] public void Boundary_EfcLeftAtRootAndRightOnChildlessNode_RemainSilentNoOps() @@ -393,16 +379,12 @@ public void Boundary_EfcLeftAtRootAndRightOnChildlessNode_RemainSilentNoOps() ); _posted.Count.Should().Be(postedBeforeRight); - // Act: four Lefts walk the active node to the root and then collapse to the root. - for (int i = 0; i < 4; i++) + // Act + Assert: five Lefts post nothing at all. + for (int i = 0; i < 5; i++) { Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Left\"}"); } - int postedAfterWalk = _posted.Count; - - // Act + Assert: the fifth Left is refused at the root and posts nothing. - Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Left\"}"); - _posted.Count.Should().Be(postedAfterWalk); + _posted.Count.Should().Be(postedBeforeRight); } /// diff --git a/QuickFiler.Test/QuickFiler.Test.csproj b/QuickFiler.Test/QuickFiler.Test.csproj index 3ebdaad51..6298db20c 100644 --- a/QuickFiler.Test/QuickFiler.Test.csproj +++ b/QuickFiler.Test/QuickFiler.Test.csproj @@ -70,7 +70,12 @@ + + + + + @@ -114,6 +119,7 @@ + diff --git a/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.Unhandled.cs b/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.Unhandled.cs new file mode 100644 index 000000000..4df7120b3 --- /dev/null +++ b/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.Unhandled.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace QuickFiler.Test.Viewers +{ + /// + /// Issue #1007 R8, N-1: the coordinator's handling of a router unhandledArrow output. + /// Split from BreadcrumbBridgeCoordinatorIssue1007Tests.cs to keep that file under the + /// 500-line limit; this partial reuses its private Harness. + /// + public sealed partial class BreadcrumbBridgeCoordinatorIssue1007Tests + { + /// + /// Issue #1007 R8, N-1 (D3): with the list open and the pending row expanded, an inbound + /// unhandledArrow Left is classified Route. The router echoes it as its output, so + /// the coordinator raises UnhandledArrow once and one synthetic key, and changes + /// neither the open state nor the committed folder. + /// + [TestMethod] + public void OpenUnhandledArrowLeft_OnExpandedPendingRow_RaisesUnhandledArrowFromRouterOutput() + { + // Arrange + var h = new Harness(commit: 0, open: true, expandAlpha: true); + + // Act + h.Receive("{\"type\":\"unhandledArrow\",\"direction\":\"left\"}"); + + // Assert + h.Events.Should().Equal("unhandled:Left"); + h.KeyDowns.Should().Be(1, "the inbound unhandledArrow raises one synthetic key"); + h.Coordinator.IsSelectorOpen.Should().BeTrue(); + h.Coordinator.GetSelectedFolder().Should().Be(Alpha); + } + } +} diff --git a/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs b/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs new file mode 100644 index 000000000..1ba107617 --- /dev/null +++ b/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using QuickFiler.Viewers; +using UtilitiesCS; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Test.Viewers +{ + /// + /// Issue #1007 QFC coordinator keyboard and click routing through a messenger hub over a + /// strict provider. Rows: Archive > Projects > Alpha (children Specs, Docs) and + /// Archive > Clients > Beta (no children). Events are recorded in raise order. + /// + [TestClass] + public sealed partial class BreadcrumbBridgeCoordinatorIssue1007Tests + { + private const string Projects = @"\Archive\Projects"; + private const string Alpha = Projects + @"\Alpha"; + private const string Beta = @"\Archive\Clients\Beta"; + + /// AC4: open-list Left on a non-expanded pending entry commits it and closes. + [TestMethod] + public void OpenLeft_OnNonExpandedEntry_CommitsAndCloses() + { + // Arrange + var h = new Harness(commit: 0, open: true); + h.Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Down); + // Act + h.Receive(Arrow("left")); + // Assert + h.Coordinator.IsSelectorOpen.Should().BeFalse(); + h.Coordinator.GetSelectedFolder().Should().Be(Beta); + h.Events.Should().Equal("selection", "open-state"); + } + + /// AC6 (D-Q21): open-list Right on a leaf closes the list first, then pops out. + [TestMethod] + public void OpenRight_OnLeaf_CancelsThenRaisesUnhandledArrowRight() + { + // Arrange + var h = new Harness(commit: 0, open: true); + h.Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Down); + // Act + h.Receive(Arrow("right")); + // Assert + h.Events.Should().Equal("open-state", "unhandled:Right"); + h.Coordinator.GetSelectedFolder().Should().Be(Alpha, "the original choice is kept"); + } + + /// AC8: open-list Ctrl+Left shortens the pending row and changes nothing committed. + [TestMethod] + public void SelectorKeyParent_Open_MovesPendingOnly() + { + // Arrange + var h = new Harness(commit: 0, open: true); + h.Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Down); + // Act + h.Receive(Key("parent")); + // Assert + h.Coordinator.IsSelectorOpen.Should().BeTrue(); + h.Coordinator.GetSelectedFolder().Should().Be(Alpha); + h.Events.Should().BeEmpty(); + h.LastRender().Should().Contain("\"collapsed\":true"); + } + + /// AC11: open-list Home and End move the pending highlight to the first and last entry. + [TestMethod] + public void SelectorKeyHomeEnd_Open_MovePendingToFirstAndLastVisibleEntry() + { + // Arrange + var h = new Harness(commit: 0, open: true); + // Act + h.Receive(Key("end")); + string afterEnd = h.Coordinator.PendingIdentity; + h.Receive(Key("home")); + // Assert + afterEnd.Should().Be(h.Identity(1)); + h.Coordinator.PendingIdentity.Should().Be(h.Identity(0)); + h.Events.Should().BeEmpty(); + } + + /// AC11: open-list PageDown moves the pending highlight by the page size. + [TestMethod] + public void SelectorKeyPageDown_Open_MovesPendingByPageSize() + { + // Arrange + var h = new Harness(commit: 0, open: true, expandAlpha: true); + // Act + h.Receive("{\"type\":\"selectorKey\",\"key\":\"pagedown\",\"pageSize\":2}"); + // Assert + h.LastView() + .Should() + .Contain("\"pendingNodeIdentity\":\"" + h.NodeIdentity("Docs") + "\""); + } + + /// AC4: the selector view carries the pending node identity for the page highlight. + [TestMethod] + public void SelectorView_PendingNode_CarriesPendingNodeIdentity() + { + // Arrange + var h = new Harness(commit: 0, open: true, expandAlpha: true); + // Act + h.Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Down); + // Assert + h.LastView() + .Should() + .Contain("\"pendingNodeIdentity\":\"" + h.NodeIdentity("Specs") + "\""); + } + + /// AC4: Enter on a pending node commits the descendant and raises one selection change. + [TestMethod] + public void SelectorKeyEnter_Open_WithPendingNode_CommitsDescendantAndRaisesSelectionChanged() + { + // Arrange + var h = new Harness(commit: 0, open: true, expandAlpha: true); + h.Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Down); + // Act + h.Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Enter); + // Assert + h.Coordinator.GetSelectedFolder().Should().Be(Alpha + @"\Specs"); + h.Events.Should().Equal("selection", "open-state"); + } + + /// AC10 (D-Q29): closed Right opens the list with the pending on the visible committed subfolder. + [TestMethod] + public void ClosedRight_OpensSelectorWithPendingOnVisibleCommittedSubfolder() + { + // Arrange + var h = new Harness(commit: 0, expandAlpha: true); + h.CommitSpecs(); + // Act + h.Receive(Arrow("right")); + // Assert + h.Coordinator.IsSelectorOpen.Should().BeTrue(); + h.LastView() + .Should() + .Contain("\"pendingNodeIdentity\":\"" + h.NodeIdentity("Specs") + "\""); + h.Events.Should().Equal("open-state"); + h.KeyDowns.Should().Be(0, "a consumed arrow raises no synthetic key"); + } + + /// AC10 (D-Q29): with the committed subfolder hidden, closed Right pends its row. + [TestMethod] + public void ClosedRight_SubfolderHidden_PendingOnRow() + { + // Arrange + var h = new Harness(commit: 0, expandAlpha: true); + h.CommitSpecs(); + h.Receive("{\"type\":\"affordanceToggle\",\"rowIndex\":0}"); + // Act + h.Receive(Arrow("right")); + // Assert + h.Coordinator.IsSelectorOpen.Should().BeTrue(); + h.Coordinator.PendingIdentity.Should().Be(h.Identity(0)); + h.LastView().Should().NotContain("pendingNodeIdentity"); + } + + /// AC10 (D-Q14): closed Left is consumed: no event, no synthetic key, no change. + [TestMethod] + public void ClosedLeft_IsConsumedWithoutEffect() + { + // Arrange + var h = new Harness(commit: 0); + // Act + h.Receive(Arrow("left")); + // Assert + h.Events.Should().BeEmpty(); + h.KeyDowns.Should().Be(0); + h.Coordinator.IsSelectorOpen.Should().BeFalse(); + h.Coordinator.GetSelectedFolder().Should().Be(Alpha); + } + + /// AC10 (D-Q16): closed Home and End commit the first and last top-level row. + [TestMethod] + public void ClosedHomeEnd_CommitFirstAndLastTopLevelRow() + { + // Arrange + var h = new Harness(commit: 0, expandAlpha: true); + // Act + h.Receive(Key("end")); + string afterEnd = h.Coordinator.GetSelectedFolder(); + h.Receive(Key("home")); + // Assert + afterEnd.Should().Be(Beta); + h.Coordinator.GetSelectedFolder().Should().Be(Alpha); + h.Events.Should().Equal("selection", "selection"); + h.Coordinator.IsSelectorOpen.Should().BeFalse(); + } + + /// AC8, AC10 (D-Q19): closed Ctrl+Left commits the parent segment immediately. + [TestMethod] + public void SelectorKeyParent_Closed_CommitsImmediately() + { + // Arrange + var h = new Harness(commit: 0); + // Act + h.Receive(Key("parent")); + // Assert + h.Coordinator.GetSelectedFolder().Should().Be(Projects); + h.Coordinator.IsSelectorOpen.Should().BeFalse(); + h.Events.Should().Equal("selection"); + h.LastRender().Should().Contain("\"collapsed\":true"); + } + + /// Guard (AC10, D-Q25): closed PageUp and PageDown do nothing. + [TestMethod] + public void ClosedPageKeys_AreNoOps() + { + // Arrange + var h = new Harness(commit: 0, expandAlpha: true); + // Act + h.Receive("{\"type\":\"selectorKey\",\"key\":\"pagedown\",\"pageSize\":2}"); + h.Receive("{\"type\":\"selectorKey\",\"key\":\"pageup\",\"pageSize\":2}"); + // Assert + h.Events.Should().BeEmpty(); + h.Coordinator.GetSelectedFolder().Should().Be(Alpha); + h.Coordinator.IsSelectorOpen.Should().BeFalse(); + } + + /// Guard (AC10, D-Q22): closed Up and Down commit the adjacent top-level row, skipping subfolders. + [TestMethod] + public void ClosedUpDown_KeepDirectCommitOfAdjacentTopLevelRow() + { + // Arrange + var h = new Harness(commit: 0, expandAlpha: true); + // Act + h.Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Down).Should().BeTrue(); + string afterDown = h.Coordinator.GetSelectedFolder(); + h.Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Up).Should().BeTrue(); + // Assert + afterDown.Should().Be(Beta); + h.Coordinator.GetSelectedFolder().Should().Be(Alpha); + h.Events.Should().Equal("selection", "selection"); + } + + /// AC9 (D-Q27, S5): an open-list segment double-click commits that segment's folder and closes. + [TestMethod] + public void SelectorSegmentDoubleClick_CommitsSegmentFolderAndCloses() + { + // Arrange + var h = new Harness(commit: 1, open: true); + // Act + h.Receive(DoubleClick(h.Identity(0), 1)); + // Assert + h.Coordinator.GetSelectedFolder().Should().Be(Projects); + h.Coordinator.IsSelectorOpen.Should().BeFalse(); + h.Events.Should().Equal("selection", "open-state"); + h.LastRender().Should().Contain("\"collapsed\":true"); + } + + /// AC9 (D-Q13): a closed-list segment double-click commits the segment folder. + [TestMethod] + public void SelectorSegmentDoubleClick_Closed_CommitsSegmentFolder() + { + // Arrange + var h = new Harness(commit: 1); + // Act + h.Receive(DoubleClick(h.Identity(0), 1)); + // Assert + h.Coordinator.GetSelectedFolder().Should().Be(Projects); + h.Coordinator.IsSelectorOpen.Should().BeFalse(); + h.Events.Should().Equal("selection"); + } + + /// AC9 (D-Q13, D-Q23): after the double-click, the [+] lists every child of the segment. + [TestMethod] + public void SelectorSegmentDoubleClick_ThenAffordanceToggle_ListsSegmentChildren() + { + // Arrange + var h = new Harness(commit: 0); + h.Receive(DoubleClick(h.Identity(0), 1)); + // Act + h.Receive("{\"type\":\"affordanceToggle\",\"rowIndex\":0}"); + // Assert + h.LastRender().Should().Contain("\"displayName\":\"Omega\""); + h.LastRender().Should().Contain("\"collapsed\":true"); + } + + /// AC9 (D-Q27): an open-list single click on a non-leaf segment moves only the pending highlight. + [TestMethod] + public void SelectorHighlight_Open_MovesPendingOnlyAndKeepsListOpen() + { + // Arrange + var h = new Harness(commit: 0, open: true); + string beta = h.Identity(1); + // Act + h.Receive( + "{\"type\":\"selectorHighlight\",\"identity\":\"" + + beta.Replace("\\", "\\\\") + + "\"}" + ); + // Assert + h.Coordinator.PendingIdentity.Should().Be(beta); + h.Coordinator.IsSelectorOpen.Should().BeTrue(); + h.Coordinator.GetSelectedFolder().Should().Be(Alpha); + h.Events.Should().BeEmpty(); + } + + private static string DoubleClick(string rowIdentity, int segmentIndex) => + "{\"type\":\"selectorSegmentDoubleClick\",\"rowIdentity\":\"" + + rowIdentity.Replace("\\", "\\\\") + + "\",\"segmentIndex\":" + + segmentIndex + + "}"; + + private static string Arrow(string direction) => + "{\"type\":\"arrowKey\",\"direction\":\"" + direction + "\"}"; + + private static string Key(string key) => + "{\"type\":\"selectorKey\",\"key\":\"" + key + "\"}"; + + private sealed class Harness + { + private readonly Page _page = new Page(); + + internal Harness(int commit, bool open = false, bool expandAlpha = false) + { + var hub = new BreadcrumbMessengerHub(); + hub.Attach(_page, BreadcrumbSelectorViewMode.Expanded); + SynchronizationContext prior = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new InlineContext()); + try + { + Coordinator = new BreadcrumbBridgeCoordinator(hub, ProviderMock().Object); + } + finally + { + SynchronizationContext.SetSynchronizationContext(prior); + } + FolderRow[] rows = { Row(Alpha), Row(Beta) }; + Coordinator + .SetSuggestionsAsync(rows, CancellationToken.None) + .GetAwaiter() + .GetResult(); + if (expandAlpha) + { + Receive("{\"type\":\"affordanceToggle\",\"rowIndex\":0}"); + } + Coordinator.SelectRow(commit); + if (open) + { + Coordinator.OpenSelector().Should().BeTrue(); + } + Coordinator.SelectionChanged += (s, e) => Events.Add("selection"); + Coordinator.SelectorOpenStateChanged += (s, e) => Events.Add("open-state"); + Coordinator.UnhandledArrow += (s, d) => Events.Add("unhandled:" + d); + Coordinator.FolderArrowKeyDown += (s, d) => KeyDowns++; + } + + internal BreadcrumbBridgeCoordinator Coordinator { get; } + + internal List Events { get; } = new List(); + + internal int KeyDowns { get; private set; } + + /// Commits the Specs subfolder of the expanded Alpha row, then clears the event log. + internal void CommitSpecs() + { + Coordinator.OpenSelector().Should().BeTrue(); + Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Down).Should().BeTrue(); + Coordinator.HandleSelectorKey(BreadcrumbSelectorKey.Enter).Should().BeTrue(); + Coordinator.GetSelectedFolder().Should().Be(Alpha + @"\Specs"); + Events.Clear(); + } + + internal void Receive(string json) + { + _page.Raise(json); + Coordinator.LastDispatch.GetAwaiter().GetResult(); + } + + internal string LastView() => _page.Posted.Last(p => p.Contains("\"selectorView\"")); + + internal string LastRender() => + _page.Posted.Last(p => p.Contains("\"type\":\"render\"")); + + internal string Identity(int row) => + Regex.Unescape( + Regex.Matches(LastView(), "\"identity\":\"([^\"]+)\"")[row].Groups[1].Value + ); + + internal string NodeIdentity(string name) => + Regex + .Match( + LastRender(), + "\"displayName\":\"" + name + "\"[^}]*?\"identity\":\"([0-9a-f]+)\"" + ) + .Groups[1] + .Value; + + private static Mock ProviderMock() + { + var provider = new Mock(MockBehavior.Strict); + Chain( + provider, + Alpha, + Seg(@"\Archive", true), + Seg(Projects, true), + Seg(Alpha, true) + ); + Chain( + provider, + Beta, + Seg(@"\Archive", true), + Seg(@"\Archive\Clients", true), + Seg(Beta, false) + ); + Children( + provider, + Alpha, + Seg(Alpha + @"\Specs", true), + Seg(Alpha + @"\Docs", false) + ); + Children(provider, Projects, Seg(Alpha, true), Seg(Projects + @"\Omega", false)); + return provider; + } + + private static void Chain( + Mock p, + string leaf, + params FolderBreadcrumbSegment[] chain + ) + { + p.Setup(x => x.ResolveLeafKeyAsync(leaf, It.IsAny())) + .ReturnsAsync(K(leaf)); + p.Setup(x => x.GetAncestorChainAsync(K(leaf), It.IsAny())) + .ReturnsAsync(chain); + } + + private static void Children( + Mock p, + string parent, + params FolderBreadcrumbSegment[] kids + ) => + p.Setup(x => + x.GetImmediateSubfoldersAsync(K(parent), It.IsAny()) + ) + .ReturnsAsync(kids); + + private static FolderRow Row(string path) => + new FolderRow(path, FolderRowKind.Suggestion, new FolderScore(path, 1000, 0.5)); + + private static FolderTreeNodeKey K(string path) => + new FolderTreeNodeKey("store", path, path); + + private static FolderBreadcrumbSegment Seg(string path, bool hasChildren) => + new FolderBreadcrumbSegment( + K(path), + path.Substring(path.LastIndexOf('\\') + 1), + path, + hasChildren + ); + } + + private sealed class Page : IWebViewMessenger + { + public event EventHandler MessageReceived; + + internal List Posted { get; } = new List(); + + public void PostJson(string json) => Posted.Add(json); + + internal void Raise(string json) => MessageReceived?.Invoke(this, json); + } + + private sealed class InlineContext : SynchronizationContext + { + public override void Post(SendOrPostCallback callback, object state) => callback(state); + } + } +} diff --git a/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs b/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs index 353fc6cdf..b1d1ea627 100644 --- a/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs +++ b/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs @@ -144,16 +144,16 @@ private static Harness CreateHarness(bool leafHasChildren = true, bool populate } // --- Positive wiring --- - + /// #1007 S5: the selector segment double-click posts the collapsed render. [TestMethod] public void InboundDoubleClick_PostsCollapsedRenderJson() { // Arrange var harness = CreateHarness(); - + string row = harness.Coordinator.CommittedIdentity.Replace("\\", "\\\\"); + string json = "{\"type\":\"selectorSegmentDoubleClick\",\"rowIdentity\":\"" + row; // Act - harness.Receive("{\"type\":\"segmentDoubleClick\",\"rowIndex\":0,\"segmentIndex\":0}"); - + harness.Receive(json + "\",\"segmentIndex\":0}"); // Assert (FR-3): the posted render payload shows the collapsed row. var render = harness.PostedMessages().OfType().Single(); render.Rows[0].Collapsed.Should().BeTrue(); @@ -256,50 +256,50 @@ public void ProviderFailure_SurfacesExplicitErrorResponse() // --- Edge events --- + /// #1007 AC6 (D-Q21): open-list Right on a leaf raises UnhandledArrow(Right) once. [TestMethod] public void UnhandledRightArrow_RaisesUnhandledArrowRight() { - // Arrange: leaf without subfolders -> Right cannot expand (legacy Pop Out fall-through). + // Arrange: leaf without subfolders, list open (a closed Right now opens the list, AC10). var harness = CreateHarness(leafHasChildren: false); var directions = new List(); harness.Coordinator.UnhandledArrow += (s, d) => directions.Add(d); - + harness.Coordinator.OpenSelector(); // Act harness.Receive("{\"type\":\"arrowKey\",\"direction\":\"right\"}"); - // Assert directions.Should().Equal(BreadcrumbArrowDirection.Right); } + /// #1007 AC10 (D-Q14): a closed Left is consumed and raises no UnhandledArrow. [TestMethod] public void UnhandledLeftArrow_RaisesUnhandledArrowLeft() { - // Arrange: nothing expanded -> Left cannot collapse (legacy close fall-through). + // Arrange: closed list, nothing expanded. var harness = CreateHarness(); var directions = new List(); harness.Coordinator.UnhandledArrow += (s, d) => directions.Add(d); - - // Act: the page-side report path is honored identically to the routed arrow path. + // Act: the page-side report path is decided identically to the routed arrow path. harness.Receive("{\"type\":\"unhandledArrow\",\"direction\":\"left\"}"); - // Assert - directions.Should().Equal(BreadcrumbArrowDirection.Left); + directions.Should().BeEmpty(); } + /// #1007 AC10: a routed open-list Right raises one synthetic key; a closed Left none. [TestMethod] public void ArrowMessages_RaiseSyntheticFolderKeyDown() { - // Arrange + // Arrange: the list is open on the expandable leaf row. var harness = CreateHarness(); var keys = new List(); harness.Coordinator.FolderArrowKeyDown += (s, d) => keys.Add(d); - - // Act: a handled Right (expands) and an unhandled Left report. + harness.Coordinator.OpenSelector(); + // Act: a routed Right (expands the pending row), Escape, then a closed Left report. harness.Receive("{\"type\":\"arrowKey\",\"direction\":\"right\"}"); + harness.Coordinator.CancelSelector(); harness.Receive("{\"type\":\"unhandledArrow\",\"direction\":\"left\"}"); - - // Assert: the synthetic key seam fires for every arrow message (FR-6). - keys.Should().Equal(BreadcrumbArrowDirection.Right, BreadcrumbArrowDirection.Left); + // Assert: only the routed arrow reaches the synthetic key seam (FR-6). + keys.Should().Equal(BreadcrumbArrowDirection.Right); } [TestMethod] diff --git a/QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs b/QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs index 96ef4d31c..5ca2c50b9 100644 --- a/QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs +++ b/QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs @@ -120,11 +120,11 @@ public void InvalidSelectorMessage_IsNoOpAndDoesNotRaiseTransitions() selections.Should().Be(0); } + /// #1007 AC10 (D-Q14): a closed Left is consumed and raises neither event. [TestMethod] public void ExistingLeftAndRightMessages_StillForwardOnce() { - // Arrange - Harness harness = CreateHarness(); + Harness harness = CreateHarness(); // Arrange int synthetic = 0; int unhandled = 0; harness.Coordinator.FolderArrowKeyDown += (sender, direction) => synthetic++; @@ -139,8 +139,8 @@ public void ExistingLeftAndRightMessages_StillForwardOnce() harness.Coordinator.LastDispatch.GetAwaiter().GetResult(); // Assert - synthetic.Should().Be(1); - unhandled.Should().Be(1); + synthetic.Should().Be(0); + unhandled.Should().Be(0); } [TestMethod] diff --git a/QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs b/QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs index da8308158..e2c530166 100644 --- a/QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs +++ b/QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs @@ -241,6 +241,10 @@ public void ActiveRow_ScrollsIntoViewOnlyInExpandedMode() ); } + /// + /// #1007 AC11: selector keys (now including Home, End, PageUp and PageDown) prevent browser + /// scrolling and post one selectorKey message; Left and Right stay off the selector map. + /// [TestMethod] public void SelectorKeys_PreventBrowserScrollingAndPostNativeKeyMessages() { @@ -249,9 +253,10 @@ public void SelectorKeys_PreventBrowserScrollingAndPostNativeKeyMessages() Html.Should().Contain("ArrowDown: \"down\""); Html.Should().Contain("Enter: \"enter\""); Html.Should().Contain("Escape: \"escape\""); + Html.Should().Contain("PageDown: \"pagedown\""); Html.Should() .MatchRegex( - @"(?s)if\s*\(Object\.prototype\.hasOwnProperty\.call\(selectorKeys,\s*event\.key\)\)\s*\{.*?event\.preventDefault\(\);.*?post\(\{\s*type:\s*\""selectorKey\"",\s*key:\s*selectorKeys\[event\.key\]\s*\}\);.*?return;\s*\}" + @"(?s)if\s*\(Object\.prototype\.hasOwnProperty\.call\(selectorKeys,\s*event\.key\)\)\s*\{.*?event\.preventDefault\(\);.*?var\s+message\s*=\s*\{\s*type:\s*\""selectorKey\"",\s*key:\s*selectorKeys\[event\.key\]\s*\};.*?post\(message\);.*?return;\s*\}" ); System.Text.RegularExpressions.Match selectorKeys = Find( @@ -309,11 +314,15 @@ public void ExpandedSubfolders_UseOneAccessibleStableIdentityActivationPath() legacy.RowIdentity.Should().Be("row"); } + /// + /// #1007 AC4: the render receiver still owns the selected-child projection, and the pending + /// descendant named by the selector view owns the active subfolder option. + /// [TestMethod] public void RenderReceiver_OwnsSelectedChildExpandedAndCollapsedProjection() { // Arrange - string selectedChildBody = FunctionBody("selectedChildForRow", "rowHasOpenAffordance"); + string selectedChildBody = FunctionBody("selectedChildForRow", "lastSegmentIndex"); string makeRowBody = FunctionBody("makeRow", "makeSubfolders"); string makeSubfoldersBody = FunctionBody("makeSubfolders", "render"); string renderBody = FunctionBody("render", "onArrow"); @@ -334,6 +343,7 @@ public void RenderReceiver_OwnsSelectedChildExpandedAndCollapsedProjection() "subfolderElement.id = \"folder-option-\" + row.rowIndex + \"-node-\" + child.identity;" ); makeSubfoldersBody.Should().Contain("selectedChildOwnsActive && child.selected"); + makeSubfoldersBody.Should().Contain("child.identity === state.pendingNodeIdentity"); makeSubfoldersBody .Should() .Contain("subfolderElement.setAttribute(\"aria-selected\", String(active));"); @@ -366,6 +376,10 @@ public void RenderReceiver_OwnsSelectedChildExpandedAndCollapsedProjection() renderReceiver.Groups["body"].Value.Should().Contain("state.selectedFolder = null;"); } + /// + /// #1007 S6: Left and Right are still routed to the host, always as arrowKey; the page-side + /// unhandledArrow pre-filter is removed because C# decides against the pending entry. + /// [TestMethod] public void LeftAndRightBreadcrumbMessages_RemainSupported() { @@ -373,7 +387,7 @@ public void LeftAndRightBreadcrumbMessages_RemainSupported() Html.Should().MatchRegex(@"(?:event|ev)\.key\s*===\s*\""ArrowRight\"""); Html.Should().MatchRegex(@"(?:event|ev)\.key\s*===\s*\""ArrowLeft\"""); Html.Should().Contain("{ type: \"arrowKey\", direction: direction }"); - Html.Should().Contain("{ type: \"unhandledArrow\", direction: direction }"); + Html.Should().NotContain("{ type: \"unhandledArrow\", direction: direction }"); } [TestMethod] diff --git a/QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs b/QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs new file mode 100644 index 000000000..b211a1eee --- /dev/null +++ b/QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs @@ -0,0 +1,230 @@ +using System.Text.RegularExpressions; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using QuickFiler.Viewers; + +namespace QuickFiler.Test.Viewers +{ + /// + /// Issue #1007 string contracts for the QFC page script embedded in + /// . CI runs no node suite, so each test twins a + /// test in tests/quickfiler-breadcrumb/issue-1007-keys.test.mjs; these verify the script + /// text is present and correctly shaped, not that it executes in a browser. The class also pins + /// the stylesheet rule that draws the pending descendant the script marks sub active. + /// + [TestClass] + public sealed class FolderBreadcrumbKeysIssue1007Tests + { + private const RegexOptions Options = + RegexOptions.CultureInvariant | RegexOptions.Singleline; + + private static readonly string Html = FolderBreadcrumbDocument.Html; + + /// AC11: Home, End, PageUp and PageDown are selector keys with lowercase wire values. + [TestMethod] + public void SelectorKeys_MapHomeEndPageUpPageDown() + { + // Act + string keys = Regex + .Match(Html, @"var\s+selectorKeys\s*=\s*\{(?.*?)\};", Options) + .Groups["keys"] + .Value; + + // Assert + keys.Should().Contain("Home: \"home\""); + keys.Should().Contain("End: \"end\""); + keys.Should().Contain("PageUp: \"pageup\""); + keys.Should().Contain("PageDown: \"pagedown\""); + keys.Should().NotContain("ArrowLeft").And.NotContain("ArrowRight"); + } + + /// AC8 (S4): Ctrl+ArrowLeft posts the parent selector key before arrow routing. + [TestMethod] + public void CtrlArrowLeft_PostsParentSelectorKey() + { + // Assert + Html.Should() + .MatchRegex( + @"(?s)if\s*\(event\.key\s*===\s*""ArrowLeft""\s*&&\s*event\.ctrlKey\)\s*\{\s*event\.preventDefault\(\);\s*post\(\{\s*type:\s*""selectorKey"",\s*key:\s*""parent""\s*\}\);\s*return;" + ); + } + + /// AC11 (S3, D-Q17): page keys carry the fully visible row count, at least one. + [TestMethod] + public void PageKeys_CarryPageSizeFromFullyVisibleCount() + { + // Act + string counter = FunctionBody("fullyVisibleCount", "visiblePageSize"); + + // Assert + Html.Should().Contain("function fullyVisibleCount(rects, top, bottom)"); + counter.Should().Contain("rect.top >= top && rect.bottom <= bottom"); + counter.Should().Contain("Math.max(1, count)"); + Html.Should() + .MatchRegex( + @"(?s)if\s*\(event\.key\s*===\s*""PageUp""\s*\|\|\s*event\.key\s*===\s*""PageDown""\)\s*\{\s*message\.pageSize\s*=\s*visiblePageSize\(\);" + ); + } + + /// AC4 (S6): Left and Right always post arrowKey; the page no longer pre-filters. + [TestMethod] + public void OnArrow_AlwaysPostsArrowKey() + { + // Act + string onArrow = FunctionBody("onArrow", "hasSelectedDescendant"); + + // Assert + onArrow.Should().Contain("post({ type: \"arrowKey\", direction: direction });"); + Html.Should().NotContain("unhandledArrow"); + Html.Should().NotContain("rowHasOpenAffordance"); + } + + /// AC1, AC4: the pending descendant named by the selector view owns the active option. + [TestMethod] + public void MakeSubfolders_MarksPendingNodeActive() + { + // Act + string makeSubfolders = FunctionBody("makeSubfolders", "render"); + + // Assert + Html.Should() + .Contain( + "state.pendingNodeIdentity = typeof msg.pendingNodeIdentity === \"string\" ? msg.pendingNodeIdentity : null;" + ); + makeSubfolders.Should().Contain("row.identity === state.pendingIdentity"); + makeSubfolders.Should().Contain("child.identity === state.pendingNodeIdentity"); + makeSubfolders.Should().Contain("\"sub active\""); + } + + /// AC9 (D-Q27): an open-list click on a non-leaf segment posts selectorHighlight and stops. + [TestMethod] + public void NonLeafSegmentClick_OpenList_StopsPropagationAndPostsSelectorHighlight() + { + // Assert + Html.Should() + .MatchRegex( + @"(?s)state\.isOpen\s*&&\s*state\.viewMode\s*===\s*""expanded"".*?cell\.segmentIndex\s*!==\s*lastSegmentIndex\(row\).*?event\.stopPropagation\(\);\s*post\(\{\s*type:\s*""selectorHighlight"",\s*identity:\s*row\.identity\s*\}\);" + ); + } + + /// AC9 (S5): a segment double-click posts selectorSegmentDoubleClick with the row identity. + [TestMethod] + public void SegmentDoubleClick_PostsSelectorSegmentDoubleClick() + { + // Assert + Html.Should() + .Contain( + "post({ type: \"selectorSegmentDoubleClick\", rowIdentity: row.identity, segmentIndex: cell.segmentIndex });" + ); + Html.Should().NotContain("type: \"segmentDoubleClick\""); + } + + /// AC14 (D-Q28): a loaded child with zero children renders no toggle. + [TestMethod] + public void MakeSubfolders_LoadedChildWithoutChildren_RendersNoToggle() + { + // Act + string makeSubfolders = FunctionBody("makeSubfolders", "render"); + + // Assert + makeSubfolders + .Should() + .Contain( + "child.childrenLoaded === true && Array.isArray(child.children) && child.children.length === 0" + ); + makeSubfolders + .Should() + .MatchRegex( + @"(?s)if\s*\(!loadedEmpty\)\s*\{\s*subfolderElement\.appendChild\(toggle\);\s*\}" + ); + } + + /// + /// Issue #1007 AC4, B-1: the QFC stylesheet draws a pending descendant (.sub.active) + /// with the selection colours of a committed subfolder (.sub.selected). Without the + /// rule, the pending highlight disappears once it moves onto a subfolder. Only the + /// style block is searched, so the script's "sub active" string cannot satisfy it. + /// + [TestMethod] + public void Stylesheet_StylesPendingDescendantLikeSelectedSubfolder() + { + // Arrange + string stylesheet = Stylesheet(); + + // Act + string selectedRule = RuleDeclarations( + stylesheet, + ".sub.selected", + "the QFC stylesheet must style the committed subfolder (.sub.selected)" + ); + string pendingRule = RuleDeclarations( + stylesheet, + ".sub.active", + "the QFC stylesheet must style the pending descendant (.sub.active)" + ); + + // Assert: the positive control first, then the pending rule. + selectedRule.Should().Contain("var(--bc-sel-back)").And.Contain("var(--bc-sel-fore)"); + pendingRule + .Should() + .Contain( + "var(--bc-sel-back)", + "the QFC stylesheet must style the pending descendant (.sub.active)" + ) + .And.Contain( + "var(--bc-sel-fore)", + "the QFC stylesheet must style the pending descendant (.sub.active)" + ); + } + + private static string Stylesheet() + { + Match match = Regex.Match(Html, @"", Options); + match.Success.Should().BeTrue("the QFC document must carry a style block"); + return match.Groups["css"].Value; + } + + // Returns the declaration block of the innermost rule whose selector list names the + // selector exactly, or fails with the supplied reason when no rule names it. + private static string RuleDeclarations(string stylesheet, string selector, string reason) + { + string declarations = null; + foreach ( + Match rule in Regex.Matches( + stylesheet, + @"(?[^{}]+)\{(?[^{}]*)\}", + Options + ) + ) + { + foreach (string candidate in rule.Groups["selectors"].Value.Split(',')) + { + if (candidate.Trim() == selector) + { + declarations = rule.Groups["body"].Value; + } + } + } + + declarations.Should().NotBeNull(reason); + return declarations; + } + + private static string FunctionBody(string functionName, string nextFunctionName) + { + Match match = Regex.Match( + Html, + @"function\s+" + + Regex.Escape(functionName) + + @"\s*\([^)]*\)\s*\{(?.*?)(?=\n\s*function\s+" + + Regex.Escape(nextFunctionName) + + @"\s*\()", + Options + ); + match + .Success.Should() + .BeTrue($"the {functionName} function must precede {nextFunctionName}"); + return match.Groups["body"].Value; + } + } +} diff --git a/QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs b/QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs index 408eb6b08..6106cd6e0 100644 --- a/QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs +++ b/QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs @@ -10,13 +10,17 @@ namespace QuickFiler.Controllers /// /// Arrow-key and leaf-expansion members of the breadcrumb bridge router, relocated from /// BreadcrumbBridgeRouter.cs so that neither part of the type exceeds the 500-line - /// file limit. Behavior is unchanged by the relocation. + /// file limit. Right and Left follow the #1007 decision table over the visible order: Right + /// expands an entry with children or moves to its first child; Left collapses an expanded + /// entry and never selects a parent (the #440 walk and the Left segment hiding are removed). /// public sealed partial class BreadcrumbBridgeRouter { private async Task HandleLeafToggleAsync(BreadcrumbRow row) { - if (row.IsCollapsed) + // R3: only a collapse that hides the active segment is restored; a row shortened at + // its active segment expands that segment instead (AC9, D-Q23). + if (row.IsCollapsed && !row.IsAnchoredCollapse) { if (row.ReExpand()) { @@ -28,137 +32,118 @@ private async Task HandleLeafToggleAsync(BreadcrumbRow row) if (row.IsLeafExpanded) { - if (row.ToggleLeafExpanded()) - { - PostRowRender(row); - } - + CollapseRowLeaf(row); return; } await ExpandLeafAsync(row); } - private async Task HandleArrowKeyAsync(BreadcrumbRow row, string key) + private async Task HandleArrowKeyAsync(BreadcrumbRow row, string key, int? pageSize) { switch (key) { case "Right": - // #440: attempt the tree transition first. It is available only on a row - // whose ACTIVE segment is an attached non-leaf with subfolders; otherwise the - // decision-D1 fall-through to the pre-existing behavior runs unchanged. - if (await TryRightTreeTransitionAsync(row)) - { - break; - } - - if (row.IsCollapsed) - { - if (row.ReExpand()) - { - PostRowRender(row); - } - } - else if (!row.IsLeafExpanded) - { - await ExpandLeafAsync(row); - } - + await HandleRightAsync(row); break; case "Left": - // #440: attempt the tree transition first — move the active segment exactly - // one step toward the root. ActivateSegment refuses at the root, on a - // non-suggestion row, and where no key is attached, and the decision-D1 - // fall-through to the pre-existing collapse behavior then runs unchanged. - if ( - row.ActiveSegmentIndex.HasValue - && row.ActivateSegment(row.ActiveSegmentIndex.Value - 1) - ) - { - PostRowRender(row); - break; - } - - if (row.LeftArrow()) - { - PostRowRender(row); - } - - break; - case "Up": - HandleUpArrow(row); + HandleLeft(row); break; - case "Down": - MoveSelection(row, step: 1); + case "ParentLeft": + HandleParentLeft(row); break; default: - log.Error($"Unknown breadcrumb arrow key '{key}' for row '{row.RowId}'."); + // #1007: Up, Down, Home, End and the page keys walk the visible order; the + // Navigation partial logs and ignores every other key. + HandleVerticalKey(row, key, pageSize); break; } } /// - /// #440 Right tree transition. Expands the active non-leaf segment when it is not yet - /// expanded, clearing any collapse as PART of the transition because - /// is a no-op while the row is collapsed; - /// once expanded, descends by activating child index 0 (decision D9). Returns false when - /// no transition is available, in which case the caller runs the pre-existing behavior. + /// Right (AC5, AC6): an entry with children that is not expanded is expanded; an expanded + /// entry moves to its first child; an entry without children, or a node whose fetch is + /// still in flight, is left alone. /// - private async Task TryRightTreeTransitionAsync(BreadcrumbRow row) + private async Task HandleRightAsync(BreadcrumbRow row) { - int? activeIndex = row.ActiveSegmentIndex; - if ( - row.Kind != BreadcrumbRowKind.Suggestion - || !activeIndex.HasValue - || activeIndex.Value >= row.Segments.Count - 1 - || row.ActiveSegmentKey == null - || row.ActiveSegment?.HasSubfolders != true - ) - { - return false; - } - - if (!row.IsLeafExpanded) + if (row.IsCollapsed && !row.IsAnchoredCollapse) { if (row.ReExpand()) { PostRowRender(row); } - await ExpandLeafAsync(row); - return true; + return; } - BreadcrumbSegment? child = row.GetActiveChild(0); - if (child == null) + List order = VisibleOrder(); + int index = PositionOf(order, row); + if (index < 0 || !order[index].HasChildren) { - return false; // Nothing to descend into: decision-D1 fall-through. + return; } - SelectHierarchyPath(row, child.FullPath); - return true; + BreadcrumbVisiblePosition position = order[index]; + if (!position.IsExpanded) + { + if (position.IsRowEntry) + { + await ExpandLeafAsync(row); + } + else + { + await HandleDescendantToggleAsync(row, position.NodeId!); + } + + return; + } + + // An expanded entry's first child directly follows it in pre-order at a greater + // depth; a node whose fetch is in flight has no child there yet. + int next = index + 1; + if (next < order.Count && order[next].Depth > position.Depth) + { + CommitPosition(order[next]); + } } - private void HandleUpArrow(BreadcrumbRow row) + /// Left (AC7): collapses an expanded entry; any other entry is a no-op. + private void HandleLeft(BreadcrumbRow row) { - BreadcrumbRow? previous = FindSelectable(IndexOf(row) - 1, step: -1); - if (previous == null) + List order = VisibleOrder(); + int index = PositionOf(order, row); + if (index < 0 || !order[index].IsExpanded) { - // Up at the top row: hand focus back to the search box. - PostOutbound(new BreadcrumbFocusSearchMessage()); - FocusSearchRequested?.Invoke(this, EventArgs.Empty); return; } - SelectRow(previous); + if (order[index].IsRowEntry) + { + CollapseRowLeaf(row); + } + else + { + CollapseNode(row, order[index].NodeId!); + } } - private void MoveSelection(BreadcrumbRow row, int step) + /// + /// Collapses the row's expansion. When that hides the selected subfolder of the selected + /// row, the row entry is re-committed (AC7, S1). + /// + private void CollapseRowLeaf(BreadcrumbRow row) { - BreadcrumbRow? next = FindSelectable(IndexOf(row) + step, step); - if (next != null) + bool hidesSelection = row.RowId == _selectedRowId && row.SelectedDescendantId != null; + // Callers collapse only an expanded row, so the toggle closes it; the guard keeps a + // refused toggle a silent no-op. + if (row.ToggleLeafExpanded()) { - SelectRow(next); + PostRowRender(row); + if (hidesSelection) + { + SelectRowEntry(row); + } } } @@ -170,6 +155,15 @@ private async Task ExpandLeafAsync(BreadcrumbRow row) return; // Active segment without subfolders (or non-suggestion row): no-op. } + // S7: a second Right (key auto-repeat) while the fetch is in flight issues no query. + if (!row.TryBeginLeafFetch()) + { + log.Debug( + $"Breadcrumb expand for row '{row.RowId}' ignored: a fetch is in flight." + ); + return; + } + string requestId = "req-" + (++_requestSequence); try { @@ -199,7 +193,7 @@ private async Task ExpandLeafAsync(BreadcrumbRow row) children ); row.SetLeafDescendants(children ?? Array.Empty()); - row.ToggleLeafExpanded(); + row.ExpandLeaf(); // R4: expand-to-true, never a toggle. PostOutbound(new BreadcrumbSubfolderResultMessage(requestId, row.RowId, mapped)); PostRowRender(row); } @@ -217,6 +211,10 @@ private async Task ExpandLeafAsync(BreadcrumbRow row) ex ); } + finally + { + row.EndLeafFetch(); + } } } } diff --git a/QuickFiler/Controllers/BreadcrumbBridgeRouter.Descendants.cs b/QuickFiler/Controllers/BreadcrumbBridgeRouter.Descendants.cs index 5b910b0d3..917dad9fa 100644 --- a/QuickFiler/Controllers/BreadcrumbBridgeRouter.Descendants.cs +++ b/QuickFiler/Controllers/BreadcrumbBridgeRouter.Descendants.cs @@ -11,7 +11,8 @@ namespace QuickFiler.Controllers /// Descendant toggle and activation handlers of the breadcrumb bridge router (#1002). Every /// request is addressed by a host-issued node identity that must belong to the addressed /// row's current tree (I1); a fetch result is applied only to the same row instance and the - /// same request generation that started it (I2); a toggle never changes the selection (I3); + /// same request generation that started it (I2); a toggle changes expansion only, unless the + /// collapse hides the selected node (I3 as narrowed by #1007 S1); /// and an activation commits only through SelectHierarchyPath (I4). Awaits resume on /// the captured UI context, so no lock is taken and the provider await keeps the default /// context capture (I6). @@ -20,9 +21,8 @@ public sealed partial class BreadcrumbBridgeRouter { private async Task HandleDescendantToggleAsync(BreadcrumbRow row, string nodeId) { - if (row.TryCollapseDescendant(nodeId)) + if (CollapseNode(row, nodeId)) { - PostRowRender(row); return; } @@ -87,6 +87,31 @@ out int generation PostRowRender(row); } + /// + /// Collapses a known expanded node. I3 is narrowed by #1007 S1: when the collapse hides + /// the selected descendant of the selected row, the collapsed node (its nearest visible + /// ancestor) is re-committed through SelectHierarchyPath. + /// + /// True when the node was collapsed. + private bool CollapseNode(BreadcrumbRow row, string nodeId) + { + bool hidesSelection = + row.RowId == _selectedRowId && row.IsSelectedDescendantWithin(nodeId); + if (!row.TryCollapseDescendant(nodeId)) + { + return false; + } + + PostRowRender(row); + string? path = row.GetDescendantPath(nodeId); + if (hidesSelection && path != null) + { + SelectHierarchyPath(row, path, nodeId); + } + + return true; + } + private void ActivateDescendant(BreadcrumbRow row, string nodeId) { string? path = row.GetDescendantPath(nodeId); diff --git a/QuickFiler/Controllers/BreadcrumbBridgeRouter.Navigation.cs b/QuickFiler/Controllers/BreadcrumbBridgeRouter.Navigation.cs new file mode 100644 index 000000000..54a5e5b86 --- /dev/null +++ b/QuickFiler/Controllers/BreadcrumbBridgeRouter.Navigation.cs @@ -0,0 +1,253 @@ +#nullable enable +using System; +using System.Collections.Generic; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Controllers +{ + /// + /// Issue #1007 keyboard navigation over the visible order: the top-level rows plus every shown + /// descendant, in pre-order. The position is the existing pair of the addressed row and, when + /// that row is selected, its selected descendant (no new state). A move onto a node commits + /// through SelectHierarchyPath and a move onto a row through SelectRowEntry, so + /// every keyboard selection obeys the archive-root contract (#614). All work runs on the UI + /// context; nothing here awaits or locks (I6). + /// + public sealed partial class BreadcrumbBridgeRouter + { + /// Builds the current visible order from every row. + private List VisibleOrder() + { + var order = new List(); + for (int index = 0; index < _rows.Count; index++) + { + _rows[index].AppendVisiblePositions(index, order); + } + return order; + } + + /// + /// The position of the addressed row (R2): its selected descendant when the row is the + /// selected row and the descendant is visible, otherwise its row entry; -1 when absent. + /// + private int PositionOf(IReadOnlyList order, BreadcrumbRow row) + { + int rowIndex = IndexOf(row); + if (row.RowId == _selectedRowId && row.SelectedDescendantId != null) + { + int node = BreadcrumbVisibleOrder.IndexOf( + order, + rowIndex, + row.SelectedDescendantId + ); + if (node >= 0) + { + return node; + } + } + return BreadcrumbVisibleOrder.IndexOf(order, rowIndex, null); + } + + /// + /// Up, Down, Home, End, PageUp and PageDown (AC1, AC2, AC11). Up with no previous entry + /// hands focus to the search box; Home never does. A page key needs a positive page size. + /// + private void HandleVerticalKey(BreadcrumbRow row, string key, int? pageSize) + { + List order = VisibleOrder(); + int current = PositionOf(order, row); + int target; + switch (key) + { + case "Up": + target = BreadcrumbVisibleOrder.Step(order, current, -1); + if (target < 0) + { + // Up at the first entry: hand focus back to the search box. + PostOutbound(new BreadcrumbFocusSearchMessage()); + FocusSearchRequested?.Invoke(this, EventArgs.Empty); + return; + } + break; + case "Down": + target = BreadcrumbVisibleOrder.Step(order, current, 1); + break; + case "Home": + target = BreadcrumbVisibleOrder.First(order); + break; + case "End": + target = BreadcrumbVisibleOrder.Last(order); + break; + default: + if (!TryPageDelta(key, pageSize, row.RowId, out int delta)) + { + return; + } + target = BreadcrumbVisibleOrder.Step(order, current, delta); + break; + } + + if (target >= 0 && target != current) + { + CommitPosition(order[target]); + } + } + + /// + /// D-Q26: with nothing selected (an empty row id), Down and Home select the first entry + /// and Up and End select the last; every other key is a logged no-op. + /// + private void HandleNoSelectionKey(string key) + { + List order = VisibleOrder(); + int target; + switch (key) + { + case "Down": + case "Home": + target = BreadcrumbVisibleOrder.First(order); + break; + case "Up": + case "End": + target = BreadcrumbVisibleOrder.Last(order); + break; + default: + log.Debug($"Breadcrumb key '{key}' with no selection is ignored."); + return; + } + + if (target >= 0) + { + CommitPosition(order[target]); + } + } + + // S3: a page key carries the measured fully visible row count; a missing or non-positive + // value is rejected explicitly rather than replaced by a guess. + private static bool TryPageDelta(string key, int? pageSize, string rowId, out int delta) + { + delta = 0; + if (key != "PageUp" && key != "PageDown") + { + log.Error($"Unknown breadcrumb arrow key '{key}' for row '{rowId}'."); + return false; + } + if (!pageSize.HasValue || pageSize.Value < 1) + { + log.Error( + $"Breadcrumb {key} for row '{rowId}' ignored: invalid page size '{pageSize}'." + ); + return false; + } + delta = key == "PageUp" ? -pageSize.Value : pageSize.Value; + return true; + } + + /// + /// Ctrl+Left (AC8): on a node, selects the parent entry and keeps its expansion; on a row + /// entry, performs the parent-segment double-click; with the first segment active (one + /// level below the archive root), does nothing. + /// + private void HandleParentLeft(BreadcrumbRow row) + { + List order = VisibleOrder(); + int index = PositionOf(order, row); + if (index < 0) + { + return; + } + + if (!order[index].IsRowEntry) + { + int parent = BreadcrumbVisibleOrder.ParentOf(order, index); + if (parent >= 0) + { + CommitPosition(order[parent]); + } + + return; + } + + int? active = row.ActiveSegmentIndex; + if (active.HasValue && active.Value > 0) + { + ShortenAt(row, active.Value - 1); + } + } + + /// + /// Segment double-click (AC9, S10), atomic: activate the segment when it is not active, + /// commit it, then shorten the row there with one row render. + /// + private void HandleSegmentDoubleClick(BreadcrumbRow row, int? requestedIndex) + { + // #498: the bridge is an untrusted boundary, so the segment index is validated + // here rather than relying on BreadcrumbRow.CollapseAfter to throw. An index + // that escaped this arm reached the async void host-event seam as an unhandled + // exception, which the single catch (BreadcrumbMessageException) cannot contain. + if ( + !requestedIndex.HasValue + || requestedIndex.Value < 0 + || requestedIndex.Value >= row.Segments.Count + ) + { + log.Error( + $"Inbound segmentDoubleClick for row '{row.RowId}' carries segment index " + + $"'{requestedIndex}', which is outside the valid range " + + $"[0, {row.Segments.Count - 1}]; rejected without a transition." + ); + return; + } + + if (row.Kind != BreadcrumbRowKind.Suggestion) + { + return; // Banner and pseudo rows never shorten and post nothing. + } + + ShortenAt(row, requestedIndex.Value); + } + + // Activates (when needed), commits and shortens at a non-leaf segment. A segment that + // cannot be activated (no provider key) keeps the pre-#1007 collapse-only behavior. + private void ShortenAt(BreadcrumbRow row, int segmentIndex) + { + if (segmentIndex >= row.Segments.Count - 1) + { + return; // The leaf is never shortened. + } + + if (row.ActiveSegmentIndex == segmentIndex || row.ActivateSegment(segmentIndex)) + { + SelectHierarchyPath(row, row.Segments[segmentIndex].FullPath); + } + else + { + log.Debug( + $"Breadcrumb segment {segmentIndex} of row '{row.RowId}' has no key; shortened without a commit." + ); + } + + if (row.CollapseAfter(segmentIndex)) + { + PostRowRender(row); + } + } + + /// Commits a visible entry as a click does (AC3). + private void CommitPosition(BreadcrumbVisiblePosition position) + { + BreadcrumbRow row = _rows[position.RowIndex]; + if (position.NodeId == null) + { + SelectRowEntry(row); + return; + } + + string? path = row.GetDescendantPath(position.NodeId); + if (path != null) + { + SelectHierarchyPath(row, path, position.NodeId); + } + } + } +} diff --git a/QuickFiler/Controllers/BreadcrumbBridgeRouter.Selection.cs b/QuickFiler/Controllers/BreadcrumbBridgeRouter.Selection.cs index eb6d2d71c..c3848ec82 100644 --- a/QuickFiler/Controllers/BreadcrumbBridgeRouter.Selection.cs +++ b/QuickFiler/Controllers/BreadcrumbBridgeRouter.Selection.cs @@ -18,9 +18,19 @@ private void ActivateSegment(BreadcrumbRow row, int segmentIndex) { if (!row.ActivateSegment(segmentIndex)) { - log.Error( - $"Breadcrumb segment activation rejected for row '{row.RowId}' and index '{segmentIndex}'." - ); + // #1007 S10: the first click of a double-click already activated the segment, so + // the second click's rejection is expected and logged at debug level only. + string message = + $"Breadcrumb segment activation rejected for row '{row.RowId}' and index '{segmentIndex}'."; + if (row.ActiveSegmentIndex == segmentIndex) + { + log.Debug(message + " The segment is already active."); + } + else + { + log.Error(message); + } + return; } @@ -91,6 +101,22 @@ private void SelectRow(BreadcrumbRow row) CommitSelection(row, selection); } + /// + /// #1007: selects a row entry of the visible order. A row whose active segment is the leaf + /// selects as a click does; a shortened or parent-activated row selects its active segment. + /// + private void SelectRowEntry(BreadcrumbRow row) + { + BreadcrumbSegment? active = row.ActiveSegment; + if (active == null || row.ActiveSegmentIndex == row.Segments.Count - 1) + { + SelectRow(row); + return; + } + + SelectHierarchyPath(row, active.FullPath); + } + /// /// Computes the filing value a whole-row selection commits: the trash text or the /// presented filing target, with eligible rooted targets normalized to the archive-relative diff --git a/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs b/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs index f59f9f146..a36df1f78 100644 --- a/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs +++ b/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs @@ -391,6 +391,12 @@ public void NotifyInitializationFailed(Exception failure) public async Task ProcessInboundAsync(string json) { BreadcrumbInboundMessage message = _codec.DeserializeInbound(json); + if (message.Type == BreadcrumbMessageTypes.ArrowKey && message.RowId.Length == 0) + { + HandleNoSelectionKey(message.Key!); // #1007 D-Q26: nothing is selected yet. + return; + } + BreadcrumbRow? row = FindRow(message.RowId); if (row == null) { @@ -401,33 +407,9 @@ public async Task ProcessInboundAsync(string json) switch (message.Type) { case BreadcrumbMessageTypes.SegmentDoubleClick: - { - // #498: the bridge is an untrusted boundary, so the segment index is validated - // here rather than relying on BreadcrumbRow.CollapseAfter to throw. An index - // that escaped this arm reached the async void host-event seam as an unhandled - // exception, which the single catch (BreadcrumbMessageException) cannot contain. - int? requestedIndex = message.SegmentIndex; - if ( - !requestedIndex.HasValue - || requestedIndex.Value < 0 - || requestedIndex.Value >= row.Segments.Count - ) - { - log.Error( - $"Inbound segmentDoubleClick for row '{row.RowId}' carries segment index " - + $"'{requestedIndex}', which is outside the valid range " - + $"[0, {row.Segments.Count - 1}]; rejected without a transition." - ); - break; - } - - if (row.CollapseAfter(requestedIndex.Value)) - { - PostRowRender(row); - } - + // #498 range guard and #1007 S10 atomic commit: Navigation partial. + HandleSegmentDoubleClick(row, message.SegmentIndex); break; - } case BreadcrumbMessageTypes.SegmentActivate: ActivateSegment(row, message.SegmentIndex!.Value); break; @@ -438,7 +420,7 @@ public async Task ProcessInboundAsync(string json) await HandleLeafToggleAsync(row); break; case BreadcrumbMessageTypes.ArrowKey: - await HandleArrowKeyAsync(row, message.Key!); + await HandleArrowKeyAsync(row, message.Key!, message.PageSize); break; case BreadcrumbMessageTypes.RowSelected: SelectRow(row); diff --git a/QuickFiler/QuickFiler.csproj b/QuickFiler/QuickFiler.csproj index 97f7a49f0..f11e0edbc 100644 --- a/QuickFiler/QuickFiler.csproj +++ b/QuickFiler/QuickFiler.csproj @@ -306,6 +306,7 @@ + @@ -425,6 +426,7 @@ + diff --git a/QuickFiler/Resources/FolderBreadcrumb.html b/QuickFiler/Resources/FolderBreadcrumb.html index 8e558017a..1ce6791d7 100644 --- a/QuickFiler/Resources/FolderBreadcrumb.html +++ b/QuickFiler/Resources/FolderBreadcrumb.html @@ -155,7 +155,7 @@ padding: 1px 4px 1px 24px; white-space: nowrap; } - .sub.selected { background: var(--bc-sel-back); color: var(--bc-sel-fore); } + .sub.selected, .sub.active { background: var(--bc-sel-back); color: var(--bc-sel-fore); } diff --git a/QuickFiler/Resources/FolderBreadcrumb.js b/QuickFiler/Resources/FolderBreadcrumb.js index a95d21c39..48c7a1992 100644 --- a/QuickFiler/Resources/FolderBreadcrumb.js +++ b/QuickFiler/Resources/FolderBreadcrumb.js @@ -12,7 +12,8 @@ viewMode: "collapsed", isOpen: false, committedIdentity: null, - pendingIdentity: null + pendingIdentity: null, + pendingNodeIdentity: null }; function post(msg) { @@ -72,8 +73,13 @@ return child && child.folderPath === state.selectedFolder ? child : null; } - function rowHasOpenAffordance(row) { - return row.cells.some(function (cell) { return cell.kind === "plus"; }); + function lastSegmentIndex(row) { + // The terminal segment is the row's own folder; clicking it still commits (#1007 D-Q27). + var last = -1; + row.cells.forEach(function (cell) { + if (cell.kind === "segment") { last = cell.segmentIndex; } + }); + return last; } function makeCell(row, cell) { @@ -83,9 +89,16 @@ element.className = cell.truncationEligible ? "seg trunc" : "seg"; element.textContent = cell.text; element.title = cell.text; + element.addEventListener("click", function (event) { + // #1007 D-Q27: in the open list a non-leaf segment moves only the pending highlight. + if (state.isOpen && state.viewMode === "expanded" && row.isSelectable && cell.segmentIndex !== lastSegmentIndex(row)) { + event.stopPropagation(); + post({ type: "selectorHighlight", identity: row.identity }); + } + }); element.addEventListener("dblclick", function (event) { event.stopPropagation(); - post({ type: "segmentDoubleClick", rowIndex: row.rowIndex, segmentIndex: cell.segmentIndex }); + post({ type: "selectorSegmentDoubleClick", rowIdentity: row.identity, segmentIndex: cell.segmentIndex }); }); } else if (cell.kind === "arrow") { element = document.createElement("span"); @@ -148,9 +161,12 @@ subs.className = "subs"; children.forEach(function (child, index) { var subfolderElement = document.createElement("div"); - var active = selectedChildOwnsActive && child.selected; + // #1007 AC1, AC4: a pending descendant named by the host owns the active option. + var active = state.pendingNodeIdentity !== null + ? row.identity === state.pendingIdentity && child.identity === state.pendingNodeIdentity + : selectedChildOwnsActive && child.selected; subfolderElement.id = "folder-option-" + row.rowIndex + "-node-" + child.identity; - subfolderElement.className = active ? "sub selected active" : "sub"; + subfolderElement.className = active ? (child.selected ? "sub selected active" : "sub active") : "sub"; subfolderElement.setAttribute("role", "option"); subfolderElement.setAttribute("aria-selected", String(active)); subfolderElement.setAttribute("data-depth", String(depth)); @@ -161,6 +177,7 @@ name.textContent = child.displayName; name.title = child.folderPath; subfolderElement.appendChild(name); + var loadedEmpty = child.childrenLoaded === true && Array.isArray(child.children) && child.children.length === 0; if (child.hasChildren) { var toggle = document.createElement("span"); toggle.className = "aff"; @@ -178,7 +195,10 @@ toggle.addEventListener("keydown", function (event) { if (event.key === "Enter" || event.key === " ") { toggleDescendant(event); } }); - subfolderElement.appendChild(toggle); + // #1007 AC14: no affordance on a child whose completed fetch returned zero children. + if (!loadedEmpty) { + subfolderElement.appendChild(toggle); + } } function activateSubfolder(event) { @@ -220,9 +240,10 @@ list.removeAttribute("aria-activedescendant"); visibleRows().forEach(function (row) { - var selectedChildOwnsActive = + var pendingNodeOwnsActive = state.pendingNodeIdentity !== null && row.identity === state.pendingIdentity; + var selectedChildOwnsActive = state.pendingNodeIdentity === null && selectedChildForRow(row) !== null && row.identity === state.pendingIdentity && hasSelectedDescendant(row.subfolders); - var rowElement = makeRow(row, activeRowIndex, selectedChildOwnsActive); + var rowElement = makeRow(row, activeRowIndex, selectedChildOwnsActive || pendingNodeOwnsActive); list.appendChild(rowElement); if (rowElement.classList.contains("active")) { activeRow = rowElement; @@ -249,15 +270,9 @@ } function onArrow(direction) { - // #440: the C# router owns the Left/Right tree transitions and reports unhandledArrow itself - // when none applies, so the page must not pre-filter a suggestion row by its view state. - var row = selectedRow(); - var can = - row !== null && - (row.isSuggestion || row.collapsed || row.leafExpanded || rowHasOpenAffordance(row)); - post(can - ? { type: "arrowKey", direction: direction } - : { type: "unhandledArrow", direction: direction }); + // #1007 S6: C# decides every Left and Right against the pending entry, so the page never + // pre-filters by the committed row. + post({ type: "arrowKey", direction: direction }); } function hasSelectedDescendant(children) { @@ -266,17 +281,44 @@ }); } + function fullyVisibleCount(rects, top, bottom) { + // #1007 S3, D-Q17: a page is the number of rows fully inside the list, at least one. + var count = rects.filter(function (rect) { return rect.top >= top && rect.bottom <= bottom; }).length; + return Math.max(1, count); + } + + function visiblePageSize() { + var bounds = list.getBoundingClientRect(); + var entries = Array.prototype.slice.call(list.querySelectorAll(".row")) + .concat(Array.prototype.slice.call(list.querySelectorAll(".sub"))); + var rects = entries.map(function (entry) { return entry.getBoundingClientRect(); }); + return fullyVisibleCount(rects, bounds.top, bounds.bottom); + } + var selectorKeys = { ArrowUp: "up", ArrowDown: "down", Enter: "enter", - Escape: "escape" + Escape: "escape", + Home: "home", + End: "end", + PageUp: "pageup", + PageDown: "pagedown" }; function handleKeyDown(event) { + if (event.key === "ArrowLeft" && event.ctrlKey) { + event.preventDefault(); + post({ type: "selectorKey", key: "parent" }); + return; + } if (Object.prototype.hasOwnProperty.call(selectorKeys, event.key)) { event.preventDefault(); - post({ type: "selectorKey", key: selectorKeys[event.key] }); + var message = { type: "selectorKey", key: selectorKeys[event.key] }; + if (event.key === "PageUp" || event.key === "PageDown") { + message.pageSize = visiblePageSize(); + } + post(message); return; } @@ -331,6 +373,7 @@ state.isOpen = msg.isOpen === true; state.committedIdentity = msg.committedIdentity || null; state.pendingIdentity = msg.pendingIdentity || null; + state.pendingNodeIdentity = typeof msg.pendingNodeIdentity === "string" ? msg.pendingNodeIdentity : null; state.options = Array.isArray(msg.options) ? msg.options : []; document.documentElement.setAttribute("data-mode", state.viewMode); document.documentElement.setAttribute("data-view-mode", state.viewMode); diff --git a/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.Keyboard.cs b/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.Keyboard.cs new file mode 100644 index 000000000..25d8a0ebf --- /dev/null +++ b/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.Keyboard.cs @@ -0,0 +1,165 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Viewers +{ + /// + /// Issue #1007 keyboard routing for the QFC selector: selector keys (including Ctrl+Left, + /// Home, End and the page keys) and the page arrowKey and unhandledArrow messages, + /// which pass through one decision against the pending entry of an open list (R8). + /// + public sealed partial class BreadcrumbBridgeCoordinator + { + /// + /// Applies native combo-box key semantics: closed arrows commit, open arrows move pending, + /// Enter commits the pending entry, and Escape restores the opening selection. Open-list + /// Ctrl+Left (), Home, End, PageUp and PageDown + /// move only the pending highlight (AC8, AC11). + /// + /// The selector key; page keys need a page size and are no-ops here. + /// True when the key changed selector state. + public bool HandleSelectorKey(BreadcrumbSelectorKey key) => + HandleSelectorKeyCore(key, null); + + /// + /// with the page size carried by a selectorKey + /// message. Kept separate so the public one-argument signature, which existing callers bind + /// by name, is unchanged (#1007 R8). + /// + /// The selector key. + /// + /// The fully visible row count for PageUp and PageDown; a missing or non-positive value + /// makes a page key a no-op (S3). + /// + private bool HandleSelectorKeyCore(BreadcrumbSelectorKey key, int? pageSize) + { + switch (key) + { + case BreadcrumbSelectorKey.Up: + return MoveSelector(previous: true); + case BreadcrumbSelectorKey.Down: + return MoveSelector(previous: false); + case BreadcrumbSelectorKey.Enter: + return CommitSelector(); + case BreadcrumbSelectorKey.Escape: + return CancelSelector(); + case BreadcrumbSelectorKey.Parent: + return ApplyTransition(_router.MoveSelectorToParent()); + case BreadcrumbSelectorKey.Home: + case BreadcrumbSelectorKey.End: + // Closed Home and End commit the first and last top-level row (D-Q16). + return ApplyTransition( + IsSelectorOpen + ? _router.MoveSelectorToBoundary(key == BreadcrumbSelectorKey.Home) + : _router.CommitSelectorBoundaryRow(key == BreadcrumbSelectorKey.Home) + ); + case BreadcrumbSelectorKey.PageUp: + case BreadcrumbSelectorKey.PageDown: + return pageSize.HasValue + && ApplyTransition( + _router.MoveSelectorPage( + previous: key == BreadcrumbSelectorKey.PageUp, + pageSize.Value + ) + ); + default: + return false; + } + } + + /// + /// Routes a page arrowKey or unhandledArrow message through one decision + /// (R8). Closed: Right opens the list and Left is consumed (D-Q14). Open: Left on a + /// non-expanded pending entry commits and closes (D-Q15); Right on a pending entry without + /// children closes the list keeping the original choice and then raises + /// for the pop-out (D-Q21). A consumed arrow raises no + /// . Every other open-list arrow keeps the router route and + /// then republishes the selector state, because the pending entry may have moved. + /// + /// True when the message was handled here; false to take the default route. + private async Task TryDispatchOpenArrowAsync(string json) + { + BreadcrumbArrowDirection? direction = ArrowDirection(json); + if (direction == null) + { + return false; + } + if (!IsSelectorOpen) + { + if (direction.Value == BreadcrumbArrowDirection.Right) + { + await _dispatcher.Dispatch(() => OpenSelector()).ConfigureAwait(false); + } + return true; + } + switch (_router.ClassifyPendingArrow(direction.Value)) + { + case BreadcrumbPendingArrowAction.CommitAndClose: + await _dispatcher.Dispatch(() => CommitSelector()).ConfigureAwait(false); + return true; + case BreadcrumbPendingArrowAction.CancelAndPopOut: + await _dispatcher.Dispatch(() => CancelSelector()).ConfigureAwait(false); + await _dispatcher + .Dispatch(() => UnhandledArrow?.Invoke(this, direction.Value)) + .ConfigureAwait(false); + return true; + default: + await DispatchAsync(json).ConfigureAwait(false); + await _dispatcher + .Dispatch(() => PostSelectorStateCore(_router.GetSelectorState())) + .ConfigureAwait(false); + return true; + } + } + + /// + /// Applies the #1007 selector click messages dispatched from the selector channel: a + /// segment double-click commits that segment's folder and closes (AC9, S5), and an open-list + /// single click on a non-leaf segment moves only the pending highlight (D-Q27). + /// + private void HandleSelectorClick(BreadcrumbSelectorMessage message) + { + switch (message) + { + case BreadcrumbSelectorSegmentDoubleClickMessage doubleClick: + ApplyTransition( + _router.DoubleClickSelectorSegment( + doubleClick.RowIdentity, + doubleClick.SegmentIndex + ) + ); + break; + case BreadcrumbSelectorHighlightMessage highlight: + ApplyTransition(_router.HighlightSelector(highlight.Identity)); + break; + } + } + + private static BreadcrumbArrowDirection? ArrowDirection(string json) + { + if (!(MessageType(json) is "arrowKey" or "unhandledArrow")) + { + return null; + } + try + { + switch (BreadcrumbBridgeSerializer.Parse(json)) + { + case ArrowKeyMessage arrow: + return arrow.Direction; + case UnhandledArrowMessage report: + return report.Direction; + default: + return null; + } + } + catch (FormatException) + { + // Malformed arrows take the default route and surface as a router error response. + return null; + } + } + } +} diff --git a/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs b/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs index c4660b0b4..62ffa41aa 100644 --- a/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs +++ b/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs @@ -157,27 +157,6 @@ public void SelectItem(string item) /// Starts a pending selector session without changing the committed selection. public bool OpenSelector() => ApplyTransition(_router.OpenSelector()); - /// - /// Applies native combo-box key semantics: closed arrows commit, open arrows move pending, - /// Enter commits the pending identity, and Escape restores the opening identity. - /// - public bool HandleSelectorKey(BreadcrumbSelectorKey key) - { - switch (key) - { - case BreadcrumbSelectorKey.Up: - return MoveSelector(previous: true); - case BreadcrumbSelectorKey.Down: - return MoveSelector(previous: false); - case BreadcrumbSelectorKey.Enter: - return CommitSelector(); - case BreadcrumbSelectorKey.Escape: - return CancelSelector(); - default: - return false; - } - } - /// Commits a row activated by stable identity and closes an open session. public bool ActivateSelector(string identity) => ApplyTransition(_router.ActivateSelector(identity)); @@ -263,7 +242,8 @@ private void PostSelectorStateCore(BreadcrumbSelectorState state) BreadcrumbSelectorViewMode.Collapsed, state.IsOpen, state.CommittedIdentity, - state.PendingIdentity + state.PendingIdentity, + state.PendingNodeIdentity ) ); var options = state @@ -315,6 +295,10 @@ private async Task DispatchInboundMessageAsync(string json) await _dispatcher.Dispatch(() => HandleSelectorMessage(json)).ConfigureAwait(false); return; } + if (await TryDispatchOpenArrowAsync(json).ConfigureAwait(false)) + { + return; + } await DispatchAsync(json).ConfigureAwait(false); } @@ -371,7 +355,7 @@ private void HandleSelectorMessage(string json) } break; case BreadcrumbSelectorKeyMessage key: - HandleSelectorKey(key.Key); + HandleSelectorKeyCore(key.Key, key.PageSize); break; case BreadcrumbSelectorActivationMessage activation: ActivateSelector(activation.Identity); @@ -384,6 +368,9 @@ private void HandleSelectorMessage(string json) ) ); break; + case BreadcrumbSelectorMessage click: + HandleSelectorClick(click); + break; } } catch (FormatException) diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeJsKeysIssue1007Tests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeJsKeysIssue1007Tests.cs new file mode 100644 index 000000000..ea1d5c777 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeJsKeysIssue1007Tests.cs @@ -0,0 +1,93 @@ +#nullable enable +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Issue #1007 string contracts for the EFC page script + /// . The repository has no JS engine in its test + /// suite, so these verify the script text is present and correctly shaped (the + /// Issue995BridgeJsPostsConfirmationForPlainEnterAndScrollsSelectedRowIntoView precedent), + /// not that it executes in WebView2. + /// + [TestClass] + public sealed class BreadcrumbBridgeJsKeysIssue1007Tests + { + private static readonly string BridgeJs = BreadcrumbDocumentAssets.BridgeJs; + + /// AC8 (S4): Ctrl+ArrowLeft posts the ParentLeft arrow key. + [TestMethod] + public void BridgeJs_CtrlArrowLeft_PostsParentLeft() + { + // Assert + BridgeJs + .Should() + .Contain("if (e.ctrlKey && e.key === 'ArrowLeft') { key = 'ParentLeft'; }"); + BridgeJs + .Should() + .Contain("var message = { type: 'arrowKey', rowId: id || '', key: key };"); + BridgeJs.Should().Contain("post(message);"); + } + + /// AC8 (D-Q20): Ctrl modifies only ArrowLeft, so Ctrl+ArrowRight posts Right. + [TestMethod] + public void BridgeJs_CtrlArrowRight_PostsRight() + { + // Assert + BridgeJs.Should().Contain("ArrowRight: 'Right'"); + BridgeJs.Should().Contain("if (e.ctrlKey && e.key === 'ArrowLeft')"); + BridgeJs.Should().NotContain("e.key === 'ArrowRight'"); + } + + /// AC11 (D-Q8): Home, End, PageUp and PageDown map to their EFC key values. + [TestMethod] + public void BridgeJs_HomeEndPageKeys_MapToKeyValues() + { + // Assert + BridgeJs + .Should() + .Contain("Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown'"); + } + + /// AC11 (S3, D-Q17): page keys carry the measured fully visible row count, at least one. + [TestMethod] + public void BridgeJs_PageKeys_CarryMeasuredPageSize() + { + // Assert + BridgeJs + .Should() + .Contain( + "if (key === 'PageUp' || key === 'PageDown') { message.pageSize = fullyVisibleCount(); }" + ); + BridgeJs.Should().Contain("function fullyVisibleCount()"); + BridgeJs.Should().Contain("if (r.top >= 0 && r.bottom <= bottom) { count++; }"); + BridgeJs.Should().Contain("return Math.max(1, count);"); + } + + /// AC1: the post-message scroll keeps a selected descendant in view before its row. + [TestMethod] + public void BridgeJs_Scroll_TargetsSelectedChildBeforeSelectedRow() + { + // Assert + BridgeJs + .Should() + .Contain( + "document.querySelector('.child[aria-selected=\"true\"]') || document.querySelector('.rowwrap.selected')" + ); + BridgeJs.Should().Contain("scrollIntoView({ block: 'nearest' })"); + } + + /// Guard (D-Q26): with nothing selected the arrow key posts an empty row id. + [TestMethod] + public void BridgeJs_NoSelection_PostsEmptyRowId() + { + // Assert + BridgeJs + .Should() + .Contain("var id = selected ? selected.getAttribute('data-row-id') : '';"); + BridgeJs.Should().Contain("rowId: id || ''"); + } + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbEmptyExpanderIssue1007Tests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbEmptyExpanderIssue1007Tests.cs new file mode 100644 index 000000000..6ee387334 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbEmptyExpanderIssue1007Tests.cs @@ -0,0 +1,153 @@ +#nullable enable +using System; +using System.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Issue #1007 AC14 defence in depth: after a fetch that attached zero children, neither the + /// EFC renderer nor the QFC projection shows an expand or collapse affordance on that row or + /// node. A fetch that attached children keeps today's collapse affordance. + /// + [TestClass] + public sealed class BreadcrumbEmptyExpanderIssue1007Tests + { + private const string Clients = @"\Archive\Clients"; + private const string North = Clients + @"\North"; + private const string Q3 = North + @"\Q3"; + + /// AC14: an expanded EFC row whose fetch returned no child shows no leaf affordance. + [TestMethod] + public void EfcRenderer_ZeroChildrenFetch_RendersNoAffordance() + { + // Arrange + BreadcrumbRow row = EfcRow(); + row.SetLeafDescendants(Array.Empty()).Should().BeTrue(); + row.ToggleLeafExpanded().Should().BeTrue(); + + // Act + string html = new BreadcrumbHtmlRenderer().RenderRowFragment(row, true); + + // Assert + html.Should().NotContain("data-role=\"leaf\"", "the fetch attached zero children"); + } + + /// AC14: a loaded EFC node with zero children shows no node affordance. + [TestMethod] + public void EfcRenderer_ZeroChildrenDescendantFetch_RendersNoNodeAffordance() + { + // Arrange + BreadcrumbRow row = EfcRow(); + row.SetLeafDescendants(new[] { Segment(Q3, "Q3", true) }).Should().BeTrue(); + row.ToggleLeafExpanded().Should().BeTrue(); + string nodeId = row.DescendantNodes[0].Identity; + row.TryBeginDescendantExpand(nodeId, out _, out int generation).Should().BeTrue(); + row.TryAttachDescendantChildren( + nodeId, + generation, + Array.Empty() + ) + .Should() + .BeTrue(); + + // Act + string html = new BreadcrumbHtmlRenderer().RenderRowFragment(row, true); + + // Assert + html.Should().NotContain("data-role=\"node\"", "the node's fetch returned no child"); + } + + /// AC14: a QFC row whose fetch returned no child projects no Plus or Minus cell. + [TestMethod] + public void QfcProjection_ZeroChildrenFetch_EmitsNoPlusOrMinus() + { + // Arrange + var model = QfcModel(); + model.Rows[0].TryExpandLeaf().Should().BeTrue(); + model.Rows[0].SetSubfolders(Array.Empty()); + + // Act + var row = BreadcrumbRenderProjection.Project(model)[0]; + + // Assert + row.Cells.Select(cell => cell.Kind) + .Should() + .NotContain(BreadcrumbCellKind.Plus) + .And.NotContain(BreadcrumbCellKind.Minus); + } + + /// AC14: a loaded QFC descendant with zero children is projected without children. + [TestMethod] + public void QfcProjection_ZeroChildrenDescendantFetch_ReportsNoChildren() + { + // Arrange + var model = QfcModel(); + var stateRow = model.Rows[0]; + stateRow.TryExpandLeaf().Should().BeTrue(); + stateRow.SetSubfolders(new[] { Segment(Q3, "Q3", true) }); + var node = stateRow.DescendantRoot!.Children[0]; + node.Expanded = true; + node.Attach(Array.Empty()); + + // Act + var projected = BreadcrumbRenderProjection.Project(model)[0].Subfolders[0]; + + // Assert + projected.HasChildren.Should().BeFalse("the node's fetch returned no child"); + } + + /// Guard (AC14): a fetch that attached a child keeps the EFC collapse affordance. + [TestMethod] + public void EfcRenderer_ChildrenFetch_KeepsMinusAffordance() + { + // Arrange + BreadcrumbRow row = EfcRow(); + row.SetLeafDescendants(new[] { Segment(Q3, "Q3", false) }).Should().BeTrue(); + row.ToggleLeafExpanded().Should().BeTrue(); + + // Act + string html = new BreadcrumbHtmlRenderer().RenderRowFragment(row, true); + + // Assert + html.Should().Contain("data-role=\"leaf\">−"); + } + + private static BreadcrumbRow EfcRow() => + new BreadcrumbRow( + "row-0", + BreadcrumbRowKind.Suggestion, + new[] + { + new BreadcrumbSegment(Clients, "Clients", true), + new BreadcrumbSegment(North, "North", true), + }, + null + ); + + private static BreadcrumbStateModel QfcModel() + { + var model = new BreadcrumbStateModel(); + model.AddSuggestionRow( + new[] { Segment(Clients, "Clients", true), Segment(North, "North", true) }, + 0.5 + ); + model.SelectRow(0); + return model; + } + + private static FolderBreadcrumbSegment Segment( + string path, + string name, + bool hasChildren + ) => + new FolderBreadcrumbSegment( + new FolderTreeNodeKey("store", path, path), + name, + path, + hasChildren + ); + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs index db8d8e3aa..a14927f2c 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs @@ -219,16 +219,27 @@ public void SetLeafChildren_WithNull_Throws() act.Should().Throw(); } + /// + /// #1007 AC9 (R3): a collapse not anchored at the active segment is still restored by + /// Right, while a row shortened at its active segment expands that segment and stays + /// shortened. + /// [TestMethod] public void RightArrow_WhenCollapsed_RestoresFullBreadcrumb() { // Arrange var row = SuggestionRow(leafHasSubfolders: false); row.CollapseAfter(0); + var anchored = AnchoredShortenedRow(); - // Act / Assert + // Act / Assert: the non-anchored collapse is restored. row.RightArrow().Should().BeTrue(); row.IsCollapsed.Should().BeFalse(); + + // Act / Assert: the anchored row expands its active segment and stays shortened. + anchored.RightArrow().Should().BeTrue(); + anchored.IsLeafExpanded.Should().BeTrue(); + anchored.CollapsedAfterIndex.Should().Be(1); } [TestMethod] @@ -265,29 +276,21 @@ public void LeftArrow_WhenLeafExpanded_CollapsesLeafFirst() row.IsCollapsed.Should().BeFalse(); } + /// + /// #1007 AC7: Left never hides the trailing segment. On a fully expanded row it returns + /// false and leaves the breadcrumb whole (folds the two former Left-hiding tests). + /// [TestMethod] - public void LeftArrow_WhenFullyExpanded_HidesTrailingSegment() + public void LeftArrow_NeverHidesTrailingSegment() { // Arrange var row = SuggestionRow(leafHasSubfolders: false); - // Act / Assert: Left hides the leaf (collapse after the previous segment). - row.LeftArrow().Should().BeTrue(); - row.CollapsedAfterIndex.Should().Be(1); - row.VisibleSegments().Select(s => s.DisplayName).Should().Equal("Root", "Mid"); - } - - [TestMethod] - public void LeftArrow_AtRootSegment_IsNoOp() - { - // Arrange: collapse until only the root segment remains. - var row = SuggestionRow(leafHasSubfolders: false); - row.LeftArrow(); - row.LeftArrow(); - - // Act / Assert: only the root remains; further Left presses are no-ops. - row.CollapsedAfterIndex.Should().Be(0); + // Act / Assert: repeated presses change nothing. + row.LeftArrow().Should().BeFalse(); row.LeftArrow().Should().BeFalse(); + row.CollapsedAfterIndex.Should().BeNull(); + row.VisibleSegments().Select(s => s.DisplayName).Should().Equal("Root", "Mid", "Leaf"); } [TestMethod] @@ -364,16 +367,34 @@ public void TransitionSequence_CollapseReExpandLeafExpand_KeepsDataImmutable() row.LeafSegment!.FullPath.Should().Be(@"Inbox\Leaf"); } + /// + /// #1007 AC9 (R3): toggling stays a no-op for a collapse not anchored at the active + /// segment (the leaf is hidden), but expands a row shortened at its active segment. + /// [TestMethod] public void ToggleLeafExpanded_WhileCollapsed_IsNoOp() { // Arrange: the leaf is hidden while the row is collapsed. var row = SuggestionRow(leafHasSubfolders: true); row.CollapseAfter(0); + var anchored = AnchoredShortenedRow(); // Act / Assert row.ToggleLeafExpanded().Should().BeFalse(); row.IsLeafExpanded.Should().BeFalse(); + anchored.ToggleLeafExpanded().Should().BeTrue("the anchored segment's [+] expands"); + anchored.IsLeafExpanded.Should().BeTrue(); + anchored.CollapsedAfterIndex.Should().Be(1); + } + + // Root > Mid > Leaf shortened to "Root > Mid [+]" with Mid active (a segment double-click). + private static BreadcrumbRow AnchoredShortenedRow() + { + var row = SuggestionRow(leafHasSubfolders: false); + row.SetSegmentKey(1, Key(@"Inbox\Mid")); + row.ActivateSegment(1).Should().BeTrue(); + row.CollapseAfter(1).Should().BeTrue(); + return row; } } } diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs index 0226d6ba7..d27c439e9 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs @@ -171,6 +171,11 @@ public void EmptyAndNoSelectableModels_HaveDeterministicNoOpNavigation() labelsOnly.SelectedIndex.Should().Be(-1); } + /// + /// The model Right and Left on a single-segment row expand and collapse it without + /// touching the selector session. #1007: unchanged on this row; the session-aware open-list + /// decisions (AC4, AC5, AC7) live in the router Navigation partial. + /// [TestMethod] public void BreadcrumbLeftAndRightTransitions_DoNotMutateSelectorSession() { diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTreeIssue1007Tests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTreeIssue1007Tests.cs new file mode 100644 index 000000000..ef142cc94 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTreeIssue1007Tests.cs @@ -0,0 +1,416 @@ +#nullable enable +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Issue #1007 AC1, AC2, AC4, AC8, AC11: the open QFC list walks the visible order and moves + /// only the pending highlight, which can rest on a descendant node. The model holds + /// Archive > Clients > Beta (expanded: Gamma, expanded with Epsilon, then Delta) + /// and Archive > Projects > Alpha, so the visible order is Beta, Gamma, Epsilon, + /// Delta, Alpha. + /// + [TestClass] + public sealed class BreadcrumbSelectionSessionTreeIssue1007Tests + { + /// AC1: Down in the open list pends every visible descendant in pre-order. + [TestMethod] + public void SelectorKeyDown_Open_MovesPendingThroughVisibleDescendants() + { + // Arrange + var (model, session) = OpenOn(0); + var root = model.Rows[0].DescendantRoot!; + var gamma = root.Children[0]; + + // Act / Assert + session.MoveNext().Should().BeTrue(); + session.PendingNode.Should().BeSameAs(gamma); + session.MoveNext().Should().BeTrue(); + session.PendingNode.Should().BeSameAs(gamma.Children[0]); + session.MoveNext().Should().BeTrue(); + session.PendingNode.Should().BeSameAs(root.Children[1]); + session.MoveNext().Should().BeTrue(); + session.PendingNode.Should().BeNull(); + session.PendingIdentity.Should().Be(model.Rows[1].Identity); + } + + /// AC1: Up from the row below an expanded block pends its last visible descendant. + [TestMethod] + public void SelectorKeyUp_Open_FromRowBelowExpandedBlock_PendsLastVisibleDescendant() + { + // Arrange + var (model, session) = OpenOn(1); + + // Act + session.MovePrevious().Should().BeTrue(); + + // Assert + session.PendingIdentity.Should().Be(model.Rows[0].Identity); + session.PendingNode.Should().BeSameAs(model.Rows[0].DescendantRoot!.Children[1]); + } + + /// AC4: Enter on a pending node commits that descendant. + [TestMethod] + public void CommitPending_WithPendingNode_SelectsDescendant() + { + // Arrange + var (model, session) = OpenOn(1); + session.MovePrevious(); + + // Act + session.CommitPending().Should().BeTrue(); + + // Assert + model.SelectedIndex.Should().Be(0); + model.SelectedDescendant.Should().BeSameAs(model.Rows[0].DescendantRoot!.Children[1]); + session.IsOpen.Should().BeFalse(); + session.PendingNode.Should().BeNull(); + } + + /// AC4: Escape restores the original choice and clears the pending node. + [TestMethod] + public void Cancel_RestoresOriginalAndClearsPendingNode() + { + // Arrange + var (model, session) = OpenOn(1); + session.MovePrevious(); + session.PendingNode.Should().NotBeNull("the pending highlight rests on Delta"); + + // Act + session.Cancel().Should().BeTrue(); + + // Assert + session.PendingNode.Should().BeNull(); + session.CommittedIdentity.Should().Be(model.Rows[1].Identity); + model.SelectedIndex.Should().Be(1); + } + + /// AC11: Home and End pend the first and last visible entry. + [TestMethod] + public void MoveToBoundary_Open_PendsFirstAndLastVisibleEntry() + { + // Arrange + var (model, session) = OpenOn(0); + session.MoveNext(); + + // Act / Assert + session.MoveToBoundary(first: false).Should().BeTrue(); + session.PendingIdentity.Should().Be(model.Rows[1].Identity); + session.MoveToBoundary(first: true).Should().BeTrue(); + session.PendingIdentity.Should().Be(model.Rows[0].Identity); + session.PendingNode.Should().BeNull(); + } + + /// AC11: PageDown and PageUp move by the page size and clamp at the ends. + [TestMethod] + public void MovePage_Open_MovesByPageSizeAndClamps() + { + // Arrange + var (model, session) = OpenOn(0); + var gamma = model.Rows[0].DescendantRoot!.Children[0]; + + // Act / Assert + session.MovePage(previous: false, pageSize: 2).Should().BeTrue(); + session.PendingNode.Should().BeSameAs(gamma.Children[0]); + session.MovePage(previous: false, pageSize: 10).Should().BeTrue(); + session.PendingIdentity.Should().Be(model.Rows[1].Identity); + session.MovePage(previous: true, pageSize: 3).Should().BeTrue(); + session.PendingNode.Should().BeSameAs(gamma); + } + + /// S3: a zero or negative page size is a no-op. + [TestMethod] + public void MovePage_InvalidPageSize_IsNoOp() + { + // Arrange + var (model, session) = OpenOn(0); + + // Act / Assert + session.MovePage(previous: false, pageSize: 0).Should().BeFalse(); + session.MovePage(previous: true, pageSize: -1).Should().BeFalse(); + session.PendingIdentity.Should().Be(model.Rows[0].Identity); + session.PendingNode.Should().BeNull(); + } + + /// AC8: Ctrl+Left on a pending node pends its parent and keeps it expanded. + [TestMethod] + public void MoveToParent_Open_OnNode_PendsParentKeepingExpansion() + { + // Arrange: pend Epsilon. + var (model, session) = OpenOn(0); + session.MoveNext(); + session.MoveNext(); + var gamma = model.Rows[0].DescendantRoot!.Children[0]; + + // Act + session.MoveToParent().Should().BeTrue(); + + // Assert + session.PendingNode.Should().BeSameAs(gamma); + gamma.Expanded.Should().BeTrue(); + } + + /// AC8: Ctrl+Left on a pending row shortens it at the parent segment, pending only. + [TestMethod] + public void MoveToParent_Open_OnRow_ShortensPendingRowAtParentSegment() + { + // Arrange + var (model, session) = OpenOn(1); + + // Act + session.MoveToParent().Should().BeTrue(); + + // Assert + model.Rows[1].ActiveSegmentIndex.Should().Be(1); + model.Rows[1].CollapsedAfterIndex.Should().Be(1); + session.IsOpen.Should().BeTrue(); + session.CommittedIdentity.Should().Be(model.Rows[1].Identity); + } + + /// AC8: Ctrl+Left stops at the first segment of the chain. + [TestMethod] + public void MoveToParent_AtFirstSegment_IsNoOp() + { + // Arrange + var (model, session) = OpenOn(1); + session.MoveToParent().Should().BeTrue(); + session.MoveToParent().Should().BeTrue(); + + // Act / Assert + session.MoveToParent().Should().BeFalse(); + model.Rows[1].ActiveSegmentIndex.Should().Be(0); + } + + /// AC8 (D-Q24): Escape after Ctrl+Left restores the row shape and the committed value. + [TestMethod] + public void SelectorKeyParent_Open_ThenEscape_RestoresRowShapeAndValue() + { + // Arrange + var (model, session) = OpenOn(1); + string? before = BreadcrumbSelectionMap.GetSelectedFolder(model); + session.MoveToParent().Should().BeTrue(); + + // Act + session.Cancel().Should().BeTrue(); + + // Assert + model.Rows[1].ActiveSegmentIndex.Should().Be(2); + model.Rows[1].CollapsedAfterIndex.Should().BeNull(); + BreadcrumbSelectionMap.GetSelectedFolder(model).Should().Be(before); + } + + /// Guard (AC2): Down on the last visible entry keeps the pending highlight. + [TestMethod] + public void SelectorKeyDown_Open_OnLastEntry_KeepsPending() + { + // Arrange + var (model, session) = OpenOn(1); + + // Act / Assert + session.MoveNext().Should().BeFalse(); + session.PendingIdentity.Should().Be(model.Rows[1].Identity); + } + + /// Guard (AC4): open moves never change the committed value. + [TestMethod] + public void OpenMove_DoesNotChangeCommittedValue() + { + // Arrange + var (model, session) = OpenOn(0); + string? before = BreadcrumbSelectionMap.GetSelectedFolder(model); + + // Act + session.MoveNext(); + + // Assert + session.CommittedIdentity.Should().Be(model.Rows[0].Identity); + model.SelectedIndex.Should().Be(0); + BreadcrumbSelectionMap.GetSelectedFolder(model).Should().Be(before); + } + + /// AC10, AC11: open-only moves are no-ops when closed; boundary commits need a closed session. + [TestMethod] + public void BoundaryAndPageMoves_RespectSessionState() + { + // Arrange + var (model, session) = Build(0); + + // Act / Assert: closed. + session.MoveToBoundary(first: false).Should().BeFalse(); + session.MovePage(previous: false, pageSize: 2).Should().BeFalse(); + session.CommitBoundaryRow(first: true).Should().BeFalse("row 0 is already committed"); + session.CommitBoundaryRow(first: false).Should().BeTrue(); + model.SelectedIndex.Should().Be(1); + + // Act / Assert: open. + session.Open().Should().BeTrue(); + session + .CommitBoundaryRow(first: true) + .Should() + .BeFalse("an open list only moves pending"); + session.MoveToBoundary(first: false).Should().BeFalse("already on the last entry"); + } + + /// AC8 (D-Q19): closed Ctrl+Left walks node to parent node, to row, to parent segments. + [TestMethod] + public void MoveToParent_Closed_CommitsEachParentUntilFirstSegment() + { + // Arrange: Epsilon (under Gamma) is committed. + var (model, session) = Build(0); + var gamma = model.Rows[0].DescendantRoot!.Children[0]; + model.SelectDescendant(0, gamma.Children[0]); + + // Act / Assert + session.MoveToParent().Should().BeTrue(); + model.SelectedDescendant.Should().BeSameAs(gamma); + session.MoveToParent().Should().BeTrue(); + model.SelectedDescendant.Should().BeNull("depth 0 commits the row entry"); + session.MoveToParent().Should().BeTrue(); + session.MoveToParent().Should().BeTrue(); + model.Rows[0].ActiveSegmentIndex.Should().Be(0); + model.Rows[0].CollapsedAfterIndex.Should().Be(0); + session.MoveToParent().Should().BeFalse("the first segment has no parent"); + } + + /// I1: a closed Ctrl+Left whose committed node is not in the selected row changes nothing. + [TestMethod] + public void MoveToParent_Closed_ForeignCommittedNode_IsNoOp() + { + // Arrange + var (model, session) = Build(0); + var gamma = model.Rows[0].DescendantRoot!.Children[0]; + model.SelectDescendant(1, gamma); + + // Act / Assert + session.MoveToParent().Should().BeFalse(); + model.SelectedDescendant.Should().BeSameAs(gamma); + } + + /// AC8 (D-Q24): Escape restores a row that was already shortened when the list opened. + [TestMethod] + public void Cancel_AfterParentOnShortenedRow_RestoresShortenedShape() + { + // Arrange: Alpha's row is shortened at Projects before opening. + var (model, session) = Build(1); + model.Rows[1].ActivateSegment(1).Should().BeTrue(); + model.Rows[1].CollapseAfter(1); + session.Open().Should().BeTrue(); + session.MoveToParent().Should().BeTrue(); + + // Act + session.Cancel().Should().BeTrue(); + + // Assert + model.Rows[1].ActiveSegmentIndex.Should().Be(1); + model.Rows[1].CollapsedAfterIndex.Should().Be(1); + } + + /// AC4 (D-Q29): opening on a visible committed node pends it; Enter keeps it committed. + [TestMethod] + public void Open_WithVisibleCommittedNode_PendsItAndCommitKeepsIt() + { + // Arrange + var (model, session) = Build(0); + var delta = model.Rows[0].DescendantRoot!.Children[1]; + model.SelectDescendant(0, delta); + + // Act + session.Open().Should().BeTrue(); + var pending = session.PendingNode; + bool changed = session.CommitPending(); + + // Assert + pending.Should().BeSameAs(delta); + changed.Should().BeFalse("the pending node is already the committed one"); + model.SelectedDescendant.Should().BeSameAs(delta); + } + + /// AC7: reconciling a still-visible pending node keeps it; a detached one is not committed. + [TestMethod] + public void PendingNode_VisibleIsKept_DetachedIsNotCommitted() + { + // Arrange: pend Delta, then collapse Gamma, which does not hide Delta. + var (model, session) = OpenOn(0); + var root = model.Rows[0].DescendantRoot!; + var delta = root.Children[1]; + session.MoveToBoundary(first: false); + session.MovePrevious(); + root.Children[0].Collapse(); + + // Act + session.ReconcilePendingNode(); + var kept = session.PendingNode; + model + .Rows[0] + .SetSubfolders(new[] { Segment(@"\Archive\Clients\Beta\Zeta", "Zeta", false) }); + session.CommitPending(); + + // Assert + kept.Should().BeSameAs(delta); + model + .SelectedDescendant.Should() + .BeNull("a node detached by a refetch is not committed"); + model.SelectedIndex.Should().Be(0); + } + + private static (BreadcrumbStateModel, BreadcrumbSelectionSession) OpenOn(int row) + { + var (model, session) = Build(row); + session.Open().Should().BeTrue(); + return (model, session); + } + + private static (BreadcrumbStateModel, BreadcrumbSelectionSession) Build(int row) + { + var model = new BreadcrumbStateModel(); + model.AddSuggestionRow( + new[] + { + Segment(@"\Archive", "Archive", true), + Segment(@"\Archive\Clients", "Clients", true), + Segment(@"\Archive\Clients\Beta", "Beta", true), + }, + 0.6 + ); + model.AddSuggestionRow( + new[] + { + Segment(@"\Archive", "Archive", true), + Segment(@"\Archive\Projects", "Projects", true), + Segment(@"\Archive\Projects\Alpha", "Alpha", false), + }, + 0.3 + ); + var beta = model.Rows[0]; + beta.TryExpandLeaf().Should().BeTrue(); + beta.SetSubfolders( + new[] + { + Segment(@"\Archive\Clients\Beta\Gamma", "Gamma", true), + Segment(@"\Archive\Clients\Beta\Delta", "Delta", false), + } + ); + var gamma = beta.DescendantRoot!.Children[0]; + gamma.Expanded = true; + gamma.Attach( + new[] { Segment(@"\Archive\Clients\Beta\Gamma\Epsilon", "Epsilon", false) } + ); + model.SelectRow(row); + return (model, new BreadcrumbSelectionSession(model)); + } + + private static FolderBreadcrumbSegment Segment( + string path, + string name, + bool hasChildren + ) => + new FolderBreadcrumbSegment( + new FolderTreeNodeKey("store", path, path), + name, + path, + hasChildren + ); + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs index d3034e510..37742f2da 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs @@ -54,11 +54,17 @@ public void ToggleMessage_RoundTripsWithoutSelectionPayload() Property(parsed, "Type").Should().Be("selectorToggle"); } + /// Every supported selector key round-trips; #1007 AC8 and AC11 add Parent, Home, End and the page keys. [DataTestMethod] [DataRow("Up")] [DataRow("Down")] [DataRow("Enter")] [DataRow("Escape")] + [DataRow("Parent")] + [DataRow("Home")] + [DataRow("End")] + [DataRow("PageUp")] + [DataRow("PageDown")] public void SelectorKeyMessage_RoundTripsOnlySupportedKeys(string keyName) { // Arrange @@ -75,6 +81,45 @@ public void SelectorKeyMessage_RoundTripsOnlySupportedKeys(string keyName) Property(parsed, "Key").ToString().Should().Be(keyName); } + /// #1007 AC11: a page key carries the fully visible row count; other keys carry none. + [TestMethod] + public void SelectorKeyMessage_PageSize_RoundTrips() + { + // Arrange + var paged = new BreadcrumbSelectorKeyMessage(BreadcrumbSelectorKey.PageDown, 7); + var plain = new BreadcrumbSelectorKeyMessage(BreadcrumbSelectorKey.Home); + + // Act + var parsedPaged = (BreadcrumbSelectorKeyMessage)Parse(Serialize(paged)); + var parsedPlain = (BreadcrumbSelectorKeyMessage)Parse(Serialize(plain)); + + // Assert + parsedPaged.Key.Should().Be(BreadcrumbSelectorKey.PageDown); + parsedPaged.PageSize.Should().Be(7); + parsedPlain.PageSize.Should().BeNull(); + } + + /// #1007 AC4: the view message carries the pending descendant node identity. + [TestMethod] + public void ViewMessage_PendingNodeIdentity_RoundTrips() + { + // Arrange + var message = new BreadcrumbSelectorViewMessage( + BreadcrumbSelectorViewMode.Expanded, + true, + "folder-a", + "folder-b", + "node-1" + ); + + // Act + var parsed = (BreadcrumbSelectorViewMessage)Parse(Serialize(message)); + + // Assert + parsed.PendingIdentity.Should().Be("folder-b"); + parsed.PendingNodeIdentity.Should().Be("node-1"); + } + [TestMethod] public void ActivationMessage_RoundTripsStableIdentity() { @@ -307,6 +352,63 @@ public void Parse_InvalidIdentityKeyOrMode_RejectsExplicitly(string json, string } } + /// #1007 AC9 (S5): the segment double-click carries the row identity and segment index. + [TestMethod] + public void SegmentDoubleClickMessage_RoundTripsRowIdentityAndSegmentIndex() + { + // Arrange + var message = new BreadcrumbSelectorSegmentDoubleClickMessage("row-a", 1); + + // Act + var parsed = (BreadcrumbSelectorSegmentDoubleClickMessage)Parse(Serialize(message)); + + // Assert + parsed.Type.Should().Be("selectorSegmentDoubleClick"); + parsed.RowIdentity.Should().Be("row-a"); + parsed.SegmentIndex.Should().Be(1); + } + + /// #1007 AC9 (D-Q27): the open-list highlight carries the row identity. + [TestMethod] + public void HighlightMessage_RoundTripsIdentity() + { + // Arrange + var message = new BreadcrumbSelectorHighlightMessage("row-b"); + + // Act + var parsed = (BreadcrumbSelectorHighlightMessage)Parse(Serialize(message)); + + // Assert + parsed.Type.Should().Be("selectorHighlight"); + parsed.Identity.Should().Be("row-b"); + } + + /// #1007 S5: invalid double-click and highlight payloads are rejected explicitly. + [TestMethod] + public void Parse_SegmentDoubleClickWithNegativeIndex_RejectsExplicitly() + { + // Arrange + const string Negative = + "{\"type\":\"selectorSegmentDoubleClick\",\"rowIdentity\":\"row-a\",\"segmentIndex\":-1}"; + const string Blank = + "{\"type\":\"selectorSegmentDoubleClick\",\"rowIdentity\":\" \",\"segmentIndex\":0}"; + Action blankHighlight = () => new BreadcrumbSelectorHighlightMessage(" "); + Action blankRow = () => new BreadcrumbSelectorSegmentDoubleClickMessage(" ", 0); + Action negativeIndex = () => + new BreadcrumbSelectorSegmentDoubleClickMessage("row-a", -1); + + // Act / Assert + AssertParseFormatException(Negative, "segmentIndex"); + AssertParseFormatException(Blank, "rowIdentity"); + AssertParseFormatException( + "{\"type\":\"selectorHighlight\",\"identity\":\"\"}", + "identity" + ); + blankHighlight.Should().Throw(); + blankRow.Should().Throw(); + negativeIndex.Should().Throw(); + } + private static object Parse(string json) => SerializerType().GetMethod("Parse")!.Invoke(null, new object[] { json })!; diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs index 9f651ec01..83ff647e4 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs @@ -57,6 +57,10 @@ public void Sequence_ExpandListSubfoldersThenCollapse_ClearsTheList() row.TryCollapseLeaf().Should().BeFalse("already collapsed is a reported no-op"); } + /// + /// #1007 AC7: Right opens the leaf expansion and a second Right is unhandled; Left closes + /// the expansion, and the next Left is unhandled because Left never selects a parent. + /// [TestMethod] public void Arrows_RightExpandsThenLeftCollapses_UnhandledWhenNothingChanges() { @@ -70,31 +74,53 @@ public void Arrows_RightExpandsThenLeftCollapses_UnhandledWhenNothingChanges() // Right again: nothing further to expand -> unhandled (legacy fall-through signal). model.RightArrow().Should().BeFalse(); - // #440 corrected contract: Left walks the ancestor chain, so the unhandled press comes - // only once the root is active. Decision D1: the sequence is extended to the root rather - // than re-pointed at a single-segment row, because the single-segment boundary is already - // covered by ArrowKey_QfcSingleSegmentRow_TakesPreExistingCollapsePath, and re-pointing - // would delete the only sequence-level assertion over the three-segment fixture. + // Left closes the expansion; the next Left changes nothing and is unhandled. model.LeftArrow().Should().BeTrue(); model.SelectedRow.LeafExpanded.Should().BeFalse(); - model.SelectedRow.ActiveSegmentIndex.Should().Be(1); - model.LeftArrow().Should().BeTrue(); - model.SelectedRow.ActiveSegmentIndex.Should().Be(0); + model.SelectedRow.ActiveSegmentIndex.Should().Be(2); model.LeftArrow().Should().BeFalse(); + model.SelectedRow.ActiveSegmentIndex.Should().Be(2); } + /// + /// #1007 AC9 (D-Q23): Right on a row shortened at its active segment lists that segment's + /// children and keeps the row shortened; a second Right is unhandled. + /// [TestMethod] - public void RightArrow_OnCollapsedRow_ReExpandsBeforeLeafExpansion() + public void RightArrow_OnRowShortenedAtActiveSegment_ExpandsAndStaysShortened() { - // Arrange + // Arrange: Inbox becomes the active segment and the shortened terminal. var model = ModelWithSuggestion(); + model.SelectedRow.ActivateSegment(0).Should().BeTrue(); model.SelectedRow.CollapseAfter(0); - // Act + Assert: first Right restores the chain, second opens the leaf. - model.RightArrow().Should().BeTrue(); - model.SelectedRow.CollapsedAfterIndex.Should().BeNull(); + // Act + Assert model.RightArrow().Should().BeTrue(); model.SelectedRow.LeafExpanded.Should().BeTrue(); + model.SelectedRow.CollapsedAfterIndex.Should().Be(0); + model.RightArrow().Should().BeFalse(); + } + + /// + /// #1007 R3, N-1 (D3): Right on a row collapsed away from its active segment (the leaf + /// stays active while the public CollapseAfter hides it) restores the full chain and + /// does not open the leaf expansion. + /// + [TestMethod] + public void RightArrow_OnRowCollapsedAwayFromActiveSegment_RestoresChain() + { + // Arrange: the leaf (index 2) stays active while the row is collapsed after Inbox. + var model = ModelWithSuggestion(); + model.SelectedRow.CollapseAfter(0); + + // Act + bool handled = model.RightArrow(); + + // Assert + handled.Should().BeTrue(); + model.SelectedRow.CollapsedAfterIndex.Should().BeNull("the full chain is restored"); + model.SelectedRow.LeafExpanded.Should().BeFalse("the leaf expansion is not opened"); + model.SelectedRow.ActiveSegmentIndex.Should().Be(2); } [TestMethod] @@ -128,6 +154,10 @@ public void SelectSubfolder_OutOfRangeIndex_Throws() .Throw(); } + /// + /// #1007 AC7: Left collapses the expansion; the collapse hides the selected subfolder, so + /// the selection moves to its nearest visible ancestor, the row itself. + /// [TestMethod] public void LeftArrow_WithSubfolderSelected_ResetsSubfolderSelectionAndCollapses() { @@ -149,53 +179,46 @@ public void LeftArrow_WithSubfolderSelected_ResetsSubfolderSelectionAndCollapses } /// - /// #440 walk-to-root: on a three-segment chain with the leaf active and no subfolder - /// selected, each of the first two Left presses selects the parent of the currently active - /// node, and the third press at the root is unhandled. The active segment index is asserted - /// after every press so the test cannot pass on the boolean alone. + /// #1007 AC7: the #440 walk is removed, so repeated Left presses on a leaf-active, + /// non-expanded row are all unhandled and neither move the active segment nor shorten the + /// row. The active index is asserted after every press so the boolean alone cannot pass. /// [TestMethod] - public void LeftArrow_RepeatedOnThreeSegmentChain_WalksToRootThenReportsUnhandled() + public void LeftArrow_RepeatedOnNonExpandedChain_IsUnhandledAndKeepsLeafActive() { // Arrange var model = ModelWithSuggestion(); model.SelectedRow.ActiveSegmentIndex.Should().Be(2, "the row starts leaf-anchored"); - // Act + Assert: press 1 selects the parent. - model.LeftArrow().Should().BeTrue(); - model.SelectedRow.ActiveSegmentIndex.Should().Be(1); - - // Act + Assert: press 2 selects the root of the resolved chain. - model.LeftArrow().Should().BeTrue(); - model.SelectedRow.ActiveSegmentIndex.Should().Be(0); - - // Act + Assert: press 3 at the root is unhandled and leaves the root active. - model.LeftArrow().Should().BeFalse(); - model.SelectedRow.ActiveSegmentIndex.Should().Be(0); + // Act + Assert + for (int press = 0; press < 3; press++) + { + model.LeftArrow().Should().BeFalse(); + model.SelectedRow.ActiveSegmentIndex.Should().Be(2); + model.SelectedRow.CollapsedAfterIndex.Should().BeNull(); + } } /// - /// #440 walk-to-root from an open leaf expansion: the first Left both clears the expansion, - /// because the fetched subfolders belonged to the previous node, and selects the parent. The - /// walk then continues to the root with the expansion staying closed. + /// #1007 AC7: from an open leaf expansion the first Left only closes the expansion (the + /// leaf stays active) and the second Left is unhandled. /// [TestMethod] - public void LeftArrow_WalkFromAnOpenLeafExpansion_ClearsTheExpansionAndStillReachesTheRoot() + public void LeftArrow_FromOpenLeafExpansion_CollapsesThenIsUnhandled() { // Arrange var model = ModelWithSuggestion(); model.RightArrow().Should().BeTrue(); model.SelectedRow.LeafExpanded.Should().BeTrue(); - // Act + Assert: press 1 clears the expansion and selects the parent. + // Act + Assert: press 1 clears the expansion and keeps the leaf active. model.LeftArrow().Should().BeTrue(); - model.SelectedRow.ActiveSegmentIndex.Should().Be(1); + model.SelectedRow.ActiveSegmentIndex.Should().Be(2); model.SelectedRow.LeafExpanded.Should().BeFalse(); - // Act + Assert: press 2 reaches the root with the expansion still closed. - model.LeftArrow().Should().BeTrue(); - model.SelectedRow.ActiveSegmentIndex.Should().Be(0); - model.SelectedRow.LeafExpanded.Should().BeFalse(); + // Act + Assert: press 2 is unhandled. + model.LeftArrow().Should().BeFalse(); + model.SelectedRow.ActiveSegmentIndex.Should().Be(2); } [TestMethod] diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs index 3cff47186..cafd51014 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs @@ -320,10 +320,8 @@ public void Reset_RestoresInitialRowState() // --- #440 Qfc tree navigation --- /// - /// #440 Qfc Left: on a multi-segment row Left selects the row's parent node before any - /// pre-existing view transition is considered (decision D1). The parent is proven to be - /// the selected node because a following Right expands it even though the leaf segment - /// carries no expansion affordance of its own. + /// #1007 AC7: Left never selects a parent. On a non-expanded multi-segment row it is a + /// no-op (unhandled), the active segment stays the leaf and the row is not shortened. /// [TestMethod] public void LeftArrow_QfcMultiSegmentRow_SelectsParentNode() @@ -334,37 +332,32 @@ public void LeftArrow_QfcMultiSegmentRow_SelectsParentNode() // Act var handled = model.LeftArrow(); - // Assert: the tree transition handled the key without collapsing the view. - handled.Should().BeTrue("Left selects the parent node before the pre-existing path"); + // Assert + handled.Should().BeFalse("the #440 parent walk is removed"); + model.SelectedRow.ActiveSegmentIndex.Should().Be(2, "the leaf stays active"); model.SelectedRow.CollapsedAfterIndex.Should().BeNull(); model.SelectedRow.LeafExpanded.Should().BeFalse(); - - // Assert: the selected node is the parent, not the leaf. - model.RightArrow().Should().BeTrue("the selected parent node carries children"); - model.SelectedRow.LeafExpanded.Should().BeTrue(); } /// - /// #440 Qfc Right: once the selected parent node is expanded and its children are - /// attached, the next Right descends into child index 0. + /// #1007 AC9 (R3): Right on a row shortened at its active segment (Inbox -> Projects [+]) + /// expands that segment and keeps the row shortened. /// [TestMethod] public void RightArrow_QfcSelectedParentNode_ExpandsIntoChildren() { - // Arrange: select the parent node, expand it, and attach one child. + // Arrange: activate Projects and shorten the row there. var model = ModelWithSuggestion(); - model.LeftArrow(); - model.RightArrow(); - model.SelectedRow.SetSubfolders( - new[] { Segment("kid", "\\Inbox\\Projects\\Kid", "Kid", false) } - ); + model.SelectedRow.ActivateSegment(1).Should().BeTrue(); + model.SelectedRow.CollapseAfter(1); // Act var handled = model.RightArrow(); // Assert - handled.Should().BeTrue("the descent transition selects the first child"); - model.SelectedSubfolderIndex.Should().Be(0); + handled.Should().BeTrue("the anchored segment's children are listed"); + model.SelectedRow.LeafExpanded.Should().BeTrue(); + model.SelectedRow.CollapsedAfterIndex.Should().Be(1, "the row stays shortened"); } /// diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbVisibleOrderTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbVisibleOrderTests.cs new file mode 100644 index 000000000..340cef3a7 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbVisibleOrderTests.cs @@ -0,0 +1,317 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Issue #1007 AC1, AC2, AC11: the shared visible order (rows plus shown descendants in + /// pre-order) and its pure index operations, built from hand-made position lists, an EFC + /// and a QFC . + /// + [TestClass] + public sealed class BreadcrumbVisibleOrderTests + { + private const string Clients = @"\Archive\Clients"; + private const string North = Clients + @"\North"; + private const string Q3 = North + @"\Q3"; + private const string Q4 = North + @"\Q4"; + + /// AC1: Down from an expanded row visits its descendants in pre-order, then the next row. + [TestMethod] + public void Step_DownFromExpandedRow_VisitsDescendantsInPreOrder() + { + // Arrange + var order = HandOrder(); + var visited = new List(); + + // Act + for (int index = 0; index >= 0; index = BreadcrumbVisibleOrder.Step(order, index, 1)) + { + visited.Add(order[index].NodeId ?? "row" + order[index].RowIndex); + } + + // Assert + visited.Should().Equal("row0", "a", "a1", "b", "row1"); + } + + /// AC1: the children of a collapsed node are not visible, so Down skips them. + [TestMethod] + public void Step_SkipsChildrenOfCollapsedNode() + { + // Arrange: Q3 was expanded and loaded, then collapsed again. + BreadcrumbRow row = EfcRowWithTree(expandQ3: true); + string q3 = row.DescendantNodes[0].Identity; + row.TryCollapseDescendant(q3).Should().BeTrue(); + var order = new List(); + row.AppendVisiblePositions(0, order); + int fromQ3 = BreadcrumbVisibleOrder.IndexOf(order, 0, q3); + + // Act + int next = BreadcrumbVisibleOrder.Step(order, fromQ3, 1); + + // Assert + order[next].NodeId.Should().Be(row.DescendantNodes[1].Identity, "Q4 follows Q3"); + order.Should().HaveCount(3, "row, Q3 and Q4 only"); + } + + /// AC2: no wrap; stepping outward from either end, or from an invalid index, is no move. + [TestMethod] + public void Step_FromBoundaryOutward_ReturnsMinusOne() + { + // Arrange + var order = HandOrder(); + + // Act / Assert + BreadcrumbVisibleOrder.Step(order, 0, -1).Should().Be(-1); + BreadcrumbVisibleOrder.Step(order, order.Count - 1, 1).Should().Be(-1); + BreadcrumbVisibleOrder.Step(order, -1, 1).Should().Be(-1); + BreadcrumbVisibleOrder.Step(order, order.Count, -1).Should().Be(-1); + } + + /// AC11: a page move is clamped to the first and last entry without wrapping. + [TestMethod] + public void Step_PageDelta_ClampsToFirstAndLast() + { + // Arrange + var order = HandOrder(); + + // Act / Assert + BreadcrumbVisibleOrder.Step(order, 0, 2).Should().Be(2); + BreadcrumbVisibleOrder.Step(order, 1, 10).Should().Be(order.Count - 1); + BreadcrumbVisibleOrder.Step(order, 3, -10).Should().Be(0); + } + + /// AC11: Home and End resolve to the first and last entry. + [TestMethod] + public void FirstAndLast_ReturnBoundaryIndices() + { + // Arrange + var order = HandOrder(); + + // Act / Assert + BreadcrumbVisibleOrder.First(order).Should().Be(0); + BreadcrumbVisibleOrder.Last(order).Should().Be(order.Count - 1); + } + + /// Edge: an empty order has no first or last entry. + [TestMethod] + public void FirstAndLast_EmptyOrder_ReturnMinusOne() + { + // Arrange + var order = new List(); + + // Act / Assert + BreadcrumbVisibleOrder.First(order).Should().Be(-1); + BreadcrumbVisibleOrder.Last(order).Should().Be(-1); + } + + /// Negative: an unknown row or node is not located. + [TestMethod] + public void IndexOf_UnknownEntry_ReturnsMinusOne() + { + // Arrange + var order = HandOrder(); + + // Act / Assert + BreadcrumbVisibleOrder.IndexOf(order, 0, "missing").Should().Be(-1); + BreadcrumbVisibleOrder.IndexOf(order, 7, null).Should().Be(-1); + BreadcrumbVisibleOrder.IndexOf(order, 0, "a1").Should().Be(2); + BreadcrumbVisibleOrder.IndexOf(order, 1, null).Should().Be(4); + } + + /// Error: every operation rejects a null order explicitly. + [TestMethod] + public void IndexOf_NullOrder_Throws() + { + // Arrange + IReadOnlyList order = null!; + + // Act + var calls = new Action[] + { + () => BreadcrumbVisibleOrder.IndexOf(order, 0, null), + () => BreadcrumbVisibleOrder.Step(order, 0, 1), + () => BreadcrumbVisibleOrder.First(order), + () => BreadcrumbVisibleOrder.Last(order), + () => BreadcrumbVisibleOrder.ParentOf(order, 0), + }; + + // Assert + foreach (Action call in calls) + { + call.Should().Throw(); + } + } + + /// AC8: the parent of a nested node is its parent node; of a first-level node, the row. + [TestMethod] + public void ParentOf_NodeEntry_ReturnsParentIndex() + { + // Arrange + var order = HandOrder(); + + // Act / Assert + BreadcrumbVisibleOrder.ParentOf(order, 2).Should().Be(1, "a1's parent is a"); + BreadcrumbVisibleOrder.ParentOf(order, 3).Should().Be(0, "b is a first-level node"); + BreadcrumbVisibleOrder.ParentOf(order, 0).Should().Be(-1, "a row entry has no parent"); + BreadcrumbVisibleOrder.ParentOf(order, 9).Should().Be(-1, "an invalid index"); + } + + /// AC1: an expanded EFC row appends its entry, then its shown nodes in pre-order. + [TestMethod] + public void AppendVisiblePositions_ExpandedRow_AddsRowThenDescendantsInPreOrder() + { + // Arrange + BreadcrumbRow row = EfcRowWithTree(expandQ3: true); + var order = new List(); + + // Act + row.AppendVisiblePositions(3, order); + + // Assert + string q3 = row.DescendantNodes[0].Identity; + order.Select(p => p.Depth).Should().Equal(-1, 0, 1, 1, 0); + order.Should().OnlyContain(p => p.RowIndex == 3); + order[0].IsRowEntry.Should().BeTrue(); + order[0].IsExpanded.Should().BeTrue(); + order[0].HasChildren.Should().BeTrue(); + order[1].NodeId.Should().Be(q3); + order[1].IsExpanded.Should().BeTrue(); + order[2].ParentNodeId.Should().Be(q3); + order[4].NodeId.Should().Be(row.DescendantNodes[1].Identity); + order[4].HasChildren.Should().BeFalse("Q4 is a leaf"); + } + + /// Edge: a banner row is never part of the visible order. + [TestMethod] + public void AppendVisiblePositions_BannerRow_AddsNothing() + { + // Arrange + var banner = new BreadcrumbRow( + "banner", + BreadcrumbRowKind.Banner, + new[] { new BreadcrumbSegment("==== Recent", "==== Recent", false) }, + null + ); + var order = new List(); + + // Act + banner.AppendVisiblePositions(0, order); + + // Assert + order.Should().BeEmpty(); + } + + /// AC14: a node whose fetch attached zero children reports no children. + [TestMethod] + public void AppendVisiblePositions_ZeroChildrenNode_ReportsNoChildren() + { + // Arrange + BreadcrumbRow row = EfcRowWithTree(expandQ3: false); + string q3 = row.DescendantNodes[0].Identity; + row.TryBeginDescendantExpand(q3, out _, out int generation).Should().BeTrue(); + row.TryAttachDescendantChildren(q3, generation, Array.Empty()); + var order = new List(); + + // Act + row.AppendVisiblePositions(0, order); + + // Assert + order[1].NodeId.Should().Be(q3); + order[1].HasChildren.Should().BeFalse("the fetch attached zero children"); + } + + /// AC1: the QFC order skips non-selectable rows and the children of collapsed nodes. + [TestMethod] + public void VisiblePositions_QfcModel_SkipsNonSelectableRowsAndCollapsedChildren() + { + // Arrange + var model = new BreadcrumbStateModel(); + model.AddPlainRow("banner", "==== Suggestions", false); + model.AddSuggestionRow( + new[] { Segment(Clients, "Clients", true), Segment(North, "North", true) }, + 0.5 + ); + model.AddPlainRow("plain", "Inbox", true); + var row = model.Rows[1]; + row.TryExpandLeaf().Should().BeTrue(); + row.SetSubfolders(new[] { Segment(Q3, "Q3", true), Segment(Q4, "Q4", false) }); + var q3 = row.DescendantRoot!.Children[0]; + q3.Expanded = true; + q3.Attach(new[] { Segment(Q3 + @"\Invoices", "Invoices", false) }); + q3.Collapse(); + + // Act + var order = model.VisiblePositions(); + + // Assert + order.Select(p => p.RowIndex).Should().Equal(1, 1, 1, 2); + order + .Select(p => p.NodeId) + .Should() + .Equal(null, q3.Identity, row.DescendantRoot!.Children[1].Identity, null); + order[0].IsExpanded.Should().BeTrue(); + order[1].IsExpanded.Should().BeFalse(); + } + + // row0 > a > a1, row0 > b, row1 (a and row0 expanded). + private static List HandOrder() => + new List + { + new BreadcrumbVisiblePosition(0, null, null, -1, true, true), + new BreadcrumbVisiblePosition(0, "a", null, 0, true, true), + new BreadcrumbVisiblePosition(0, "a1", "a", 1, false, false), + new BreadcrumbVisiblePosition(0, "b", null, 0, false, false), + new BreadcrumbVisiblePosition(1, null, null, -1, false, false), + }; + + // Clients > North, expanded, with first level Q3 (has children) and Q4 (leaf). + private static BreadcrumbRow EfcRowWithTree(bool expandQ3) + { + var row = new BreadcrumbRow( + "row-0", + BreadcrumbRowKind.Suggestion, + new[] + { + new BreadcrumbSegment(Clients, "Clients", true), + new BreadcrumbSegment(North, "North", true), + }, + null + ); + row.SetLeafDescendants(new[] { Segment(Q3, "Q3", true), Segment(Q4, "Q4", false) }); + row.ToggleLeafExpanded(); + if (expandQ3) + { + string q3 = row.DescendantNodes[0].Identity; + row.TryBeginDescendantExpand(q3, out _, out int generation); + row.TryAttachDescendantChildren( + q3, + generation, + new[] + { + Segment(Q3 + @"\Invoices", "Invoices", false), + Segment(Q3 + @"\Receipts", "Receipts", false), + } + ); + } + return row; + } + + private static FolderBreadcrumbSegment Segment( + string path, + string name, + bool hasChildren + ) => + new FolderBreadcrumbSegment( + new FolderTreeNodeKey("store", path, path), + name, + path, + hasChildren + ); + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs index 4d328c2eb..312d63bfa 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs @@ -22,14 +22,22 @@ public sealed partial class FolderBreadcrumbBridgeRouterTests { // --- State-transition sequences across multiple routed messages --- + /// + /// #1007 S5, AC9 (D-Q23): toggle open, toggle closed, shorten through the selector + /// double-click entry point, then the toggle lists the active segment's children and the row + /// stays shortened. + /// [TestMethod] public async Task Sequence_ExpandCollapseViaMessages_TransitionsDeterministically() { // Arrange - var router = await PopulatedRouterAsync(ProviderMock()); + var provider = ProviderMock(); + provider + .Setup(p => p.GetImmediateSubfoldersAsync(MidKey, It.IsAny())) + .ReturnsAsync(new[] { Segment(Key("m1", MidChildPath), "Alpha", false) }); + var router = await PopulatedRouterAsync(provider); - // Act + Assert stepwise: toggle open -> toggle closed -> double-click collapse -> - // toggle re-expands the collapsed chain. + // Act + Assert stepwise. ( await router.RouteAsync( "{\"type\":\"affordanceToggle\",\"rowIndex\":0}", @@ -50,17 +58,22 @@ await router.RouteAsync( .ContainSingle(); router.Model.Rows[0].LeafExpanded.Should().BeFalse(); - await router.RouteAsync( - "{\"type\":\"segmentDoubleClick\",\"rowIndex\":0,\"segmentIndex\":1}", - CancellationToken.None - ); + router + .DoubleClickSelectorSegment(router.Model.Rows[0].Identity, 1) + .Handled.Should() + .BeTrue(); router.Model.Rows[0].CollapsedAfterIndex.Should().Be(1); - await router.RouteAsync( - "{\"type\":\"affordanceToggle\",\"rowIndex\":0}", - CancellationToken.None - ); - router.Model.Rows[0].CollapsedAfterIndex.Should().BeNull(); + ( + await router.RouteAsync( + "{\"type\":\"affordanceToggle\",\"rowIndex\":0}", + CancellationToken.None + ) + ) + .Should() + .HaveCount(2); + router.Model.Rows[0].LeafExpanded.Should().BeTrue(); + router.Model.Rows[0].CollapsedAfterIndex.Should().Be(1); } [TestMethod] diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.Unanchored.cs b/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.Unanchored.cs new file mode 100644 index 000000000..d5973a515 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.Unanchored.cs @@ -0,0 +1,47 @@ +#nullable enable +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Issue #1007 R3, N-1: the QFC affordanceToggle on a row whose collapse is anchored away + /// from its active segment. The public contract + /// accepts any non-leaf index whatever the active segment, so this state is reachable. The + /// toggle restores the full chain instead of listing children. + /// + public sealed partial class FolderBreadcrumbBridgeRouterIssue1007Tests + { + /// + /// Issue #1007 R3, N-1 (D3): with the leaf still active, a row collapsed after Projects is + /// restored by the [+] in one render, and no subfolder query is made. + /// + [TestMethod] + public async Task AffordanceToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChainWithoutQuery() + { + // Arrange: Archive > Projects > Alpha with Alpha active, collapsed after Projects. + var (router, provider) = await RouterAsync(); + router.Model.Rows[0].CollapseAfter(1); + + // Act + var outputs = await ExpandAlphaAsync(router); + + // Assert + outputs.Should().ContainSingle().Which.Should().Contain("\"type\":\"render\""); + router.Model.Rows[0].CollapsedAfterIndex.Should().BeNull("the full chain is restored"); + router.Model.Rows[0].LeafExpanded.Should().BeFalse("no children are listed"); + provider.Verify( + p => p.GetImmediateSubfoldersAsync(Key(Alpha), It.IsAny()), + Times.Never() + ); + provider.Verify( + p => p.GetImmediateSubfoldersAsync(Key(Projects), It.IsAny()), + Times.Never() + ); + } + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.cs new file mode 100644 index 000000000..a01c76567 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.cs @@ -0,0 +1,499 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Issue #1007 QFC router over a strict provider: with the list open, Left and Right act on + /// the pending entry (AC4, AC5, AC7), the pending arrow is classified for the coordinator, a + /// shortened row expands its active segment (AC9) and files to it (S9). Rows: + /// Archive > Projects > Alpha (Alpha: Specs with Drafts, Docs) and + /// Archive > Clients > Beta (no children). + /// + [TestClass] + public sealed partial class FolderBreadcrumbBridgeRouterIssue1007Tests + { + private const string Archive = @"\Archive"; + private const string Projects = Archive + @"\Projects"; + private const string Alpha = Projects + @"\Alpha"; + private const string Specs = Alpha + @"\Specs"; + private const string Beta = Archive + @"\Clients\Beta"; + + /// AC5: open-list Right on a pending row with children expands that row. + [TestMethod] + public async Task OpenRight_OnPendingRowWithChildren_ExpandsPendingRow() + { + // Arrange: Beta committed, Alpha pending. + var (router, provider) = await RouterAsync(); + router.SelectRow(1); + router.OpenSelector(); + router.MoveSelector(previous: true); + + // Act + await ArrowAsync(router, "right"); + + // Assert + router.Model.Rows[0].LeafExpanded.Should().BeTrue(); + provider.Verify( + p => p.GetImmediateSubfoldersAsync(Key(Alpha), It.IsAny()), + Times.Once() + ); + router.Model.SelectedIndex.Should().Be(1, "the committed choice is unchanged"); + } + + /// AC5: open-list Right on an expanded pending entry pends its first child. + [TestMethod] + public async Task OpenRight_OnExpandedPendingEntry_PendsFirstChild() + { + // Arrange + var (router, _) = await RouterAsync(); + await ExpandAlphaAsync(router); + router.SelectRow(0); + router.OpenSelector(); + + // Act + await ArrowAsync(router, "right"); + + // Assert + router.GetSelectorState().PendingNodeIdentity.Should().Be(NodeOf(router, 0).Identity); + router.GetSelectorState().IsOpen.Should().BeTrue(); + } + + /// AC7: open-list Left on an expanded pending entry collapses it; the pending stays. + [TestMethod] + public async Task OpenLeft_OnExpandedPendingEntry_CollapsesAndKeepsPending() + { + // Arrange + var (router, _) = await RouterAsync(); + await ExpandAlphaAsync(router); + router.SelectRow(0); + router.OpenSelector(); + + // Act + await ArrowAsync(router, "left"); + + // Assert + router.Model.Rows[0].LeafExpanded.Should().BeFalse(); + router.Model.Rows[0].ActiveSegmentIndex.Should().Be(2, "Left never selects a parent"); + router.GetSelectorState().PendingIdentity.Should().Be(router.Model.Rows[0].Identity); + } + + /// AC7: a collapse that hides the pending node moves it to the nearest visible ancestor. + [TestMethod] + public async Task CollapseHidingPending_MovesPendingToNearestVisibleAncestor() + { + // Arrange: Specs is expanded and its child Drafts is pending. + var (router, _) = await RouterAsync(); + await ExpandAlphaAsync(router); + var specs = NodeOf(router, 0); + await router.ToggleDescendantAsync( + router.Model.Rows[0].Identity, + specs.Identity, + CancellationToken.None + ); + router.SelectRow(0); + router.OpenSelector(); + router.MoveSelector(previous: false); + router.MoveSelector(previous: false); + + // Act + await router.ToggleDescendantAsync( + router.Model.Rows[0].Identity, + specs.Identity, + CancellationToken.None + ); + + // Assert + router.GetSelectorState().PendingNodeIdentity.Should().Be(specs.Identity); + } + + /// AC4: open-list Left on a non-expanded pending entry commits and closes. + [TestMethod] + public async Task ClassifyPendingArrow_OpenLeftOnNonExpanded_ReturnsCommitAndClose() + { + // Arrange + var (router, _) = await RouterAsync(); + router.SelectRow(1); + var closed = router.ClassifyPendingArrow(BreadcrumbArrowDirection.Left); + router.OpenSelector(); + + // Act + var open = router.ClassifyPendingArrow(BreadcrumbArrowDirection.Left); + + // Assert + closed + .Should() + .Be(BreadcrumbPendingArrowAction.Route, "a closed list routes as before"); + open.Should().Be(BreadcrumbPendingArrowAction.CommitAndClose); + } + + /// AC6: open-list Right on a pending entry without children cancels and pops out. + [TestMethod] + public async Task ClassifyPendingArrow_OpenRightOnLeaf_ReturnsCancelAndPopOut() + { + // Arrange + var (router, _) = await RouterAsync(); + router.SelectRow(1); + router.OpenSelector(); + + // Act + var onLeaf = router.ClassifyPendingArrow(BreadcrumbArrowDirection.Right); + router.MoveSelector(previous: true); + var onParent = router.ClassifyPendingArrow(BreadcrumbArrowDirection.Right); + + // Assert + onLeaf.Should().Be(BreadcrumbPendingArrowAction.CancelAndPopOut); + onParent.Should().Be(BreadcrumbPendingArrowAction.Route); + } + + /// AC9: Right on a pending row shortened by Ctrl+Left lists the active segment's children. + [TestMethod] + public async Task ArrowRight_OnShortenedPendingRow_ListsSegmentChildren() + { + // Arrange + var (router, provider) = await RouterAsync(); + router.SelectRow(0); + router.OpenSelector(); + router.MoveSelectorToParent().Handled.Should().BeTrue(); + + // Act + await ArrowAsync(router, "right"); + + // Assert + provider.Verify( + p => p.GetImmediateSubfoldersAsync(Key(Projects), It.IsAny()), + Times.Once() + ); + router.Model.Rows[0].LeafExpanded.Should().BeTrue(); + router.Model.Rows[0].CollapsedAfterIndex.Should().Be(1); + } + + /// S9 (AC8, AC9): a shortened row files to the folder it displays. + [TestMethod] + public async Task GetSelectedFolder_ShortenedRow_ReturnsActiveSegmentValue() + { + // Arrange + var (router, _) = await RouterAsync(); + router.SelectRow(0); + router.Model.Rows[0].ActivateSegment(1).Should().BeTrue(); + router.Model.Rows[0].CollapseAfter(1); + + // Act + string? selected = router.GetSelectedFolder(); + + // Assert + selected.Should().Be(Projects); + } + + /// AC9 (D-Q13, D-Q27): a selector segment double-click activates, commits, shortens and closes. + [TestMethod] + public async Task DoubleClickSelectorSegment_ActivatesCommitsAndShortens() + { + // Arrange + var (router, _) = await RouterAsync(); + router.SelectRow(1); + router.OpenSelector(); + + // Act + var transition = router.DoubleClickSelectorSegment(router.Model.Rows[0].Identity, 1); + + // Assert + transition.Handled.Should().BeTrue(); + transition.SelectionChanged.Should().BeTrue(); + transition.OpenStateChanged.Should().BeTrue(); + transition.SelectorState.IsOpen.Should().BeFalse(); + router.GetSelectedFolder().Should().Be(Projects); + router.Model.Rows[0].ActiveSegmentIndex.Should().Be(1); + router.Model.Rows[0].CollapsedAfterIndex.Should().Be(1); + } + + /// I1: an unknown row identity or an out-of-range segment index is an unhandled no-op. + [TestMethod] + public async Task DoubleClickSelectorSegment_UnknownRowOrIndex_IsUnhandled() + { + // Arrange + var (router, _) = await RouterAsync(); + router.SelectRow(0); + + // Act + var unknownRow = router.DoubleClickSelectorSegment("missing-row", 1); + var outOfRange = router.DoubleClickSelectorSegment(router.Model.Rows[0].Identity, 5); + + // Assert + unknownRow.Handled.Should().BeFalse(); + outOfRange.Handled.Should().BeFalse(); + router.Model.Rows[0].CollapsedAfterIndex.Should().BeNull(); + router.GetSelectedFolder().Should().Be(Alpha); + } + + /// AC9 (D-Q23): the [+] on a shortened row lists the active segment's children with one query. + [TestMethod] + public async Task AffordanceToggle_OnShortenedRow_ExpandsActiveSegmentWithOneQuery() + { + // Arrange + var (router, provider) = await RouterAsync(); + router + .DoubleClickSelectorSegment(router.Model.Rows[0].Identity, 1) + .Handled.Should() + .BeTrue(); + + // Act + await ExpandAlphaAsync(router); + + // Assert + provider.Verify( + p => p.GetImmediateSubfoldersAsync(Key(Projects), It.IsAny()), + Times.Once() + ); + router.Model.Rows[0].LeafExpanded.Should().BeTrue(); + router.Model.Rows[0].CollapsedAfterIndex.Should().Be(1, "the row stays shortened"); + } + + /// AC9 (D-Q18): an expanded shortened row projects the collapse affordance at its terminal. + [TestMethod] + public async Task ShortenedRowExpanded_ProjectsMinusAtTerminal() + { + // Arrange + var (router, _) = await RouterAsync(); + router + .DoubleClickSelectorSegment(router.Model.Rows[0].Identity, 1) + .Handled.Should() + .BeTrue(); + await ExpandAlphaAsync(router); + + // Act + var cells = BreadcrumbRenderProjection.Project(router.Model)[0].Cells; + + // Assert + cells.Should().Contain(cell => cell.Kind == BreadcrumbCellKind.Minus); + cells.Should().NotContain(cell => cell.Kind == BreadcrumbCellKind.Plus); + } + + /// S5: the bridge-channel segment double-click is a logged no-op without a state change. + [TestMethod] + public async Task BridgeSegmentDoubleClick_IsLoggedNoOpWithoutStateChange() + { + // Arrange + var (router, _) = await RouterAsync(); + router.SelectRow(0); + + // Act + var outputs = await router.RouteAsync( + "{\"type\":\"segmentDoubleClick\",\"rowIndex\":0,\"segmentIndex\":1}", + CancellationToken.None + ); + + // Assert + outputs.Should().BeEmpty(); + router.Model.Rows[0].CollapsedAfterIndex.Should().BeNull(); + router.GetSelectedFolder().Should().Be(Alpha); + } + + /// D-Q27: the highlight is open-list only; while open it moves only the pending entry. + [TestMethod] + public async Task HighlightSelector_Closed_IsUnhandled() + { + // Arrange + var (router, _) = await RouterAsync(); + router.SelectRow(0); + string betaIdentity = router.Model.Rows[1].Identity; + + // Act + var closed = router.HighlightSelector(betaIdentity); + router.OpenSelector(); + var open = router.HighlightSelector(betaIdentity); + + // Assert + closed.Handled.Should().BeFalse(); + open.Handled.Should().BeTrue(); + open.SelectionChanged.Should().BeFalse(); + open.SelectorState.PendingIdentity.Should().Be(betaIdentity); + router.GetSelectedFolder().Should().Be(Alpha); + } + + /// AC5, AC7: open Right expands a pending node with children; Left collapses it, pending kept. + [TestMethod] + public async Task OpenRightLeft_OnPendingNodeWithChildren_ExpandsThenCollapsesNode() + { + // Arrange: Specs is pending. + var (router, provider) = await OpenOnAlphaChildAsync(0); + var specs = NodeOf(router, 0); + + // Act + await ArrowAsync(router, "right"); + await ArrowAsync(router, "left"); + + // Assert: Right fetched Specs' children (expanded), Left collapsed it again. + provider.Verify( + p => p.GetImmediateSubfoldersAsync(Key(Specs), It.IsAny()), + Times.Once() + ); + specs.Expanded.Should().BeFalse(); + router.GetSelectorState().PendingNodeIdentity.Should().Be(specs.Identity); + } + + /// AC6, AC7: open Right and Left on a pending node without children are unhandled. + [TestMethod] + public async Task OpenArrows_OnPendingLeafNode_ReportUnhandled() + { + // Arrange: Docs is pending. + var (router, _) = await OpenOnAlphaChildAsync(1); + + // Act + var right = await ArrowAsync(router, "right"); + var left = await ArrowAsync(router, "left"); + + // Assert + IsUnhandled(right).Should().BeTrue(); + IsUnhandled(left).Should().BeTrue(); + } + + /// AC14: open Right on an expanded pending node whose fetch returned no children is unhandled. + [TestMethod] + public async Task OpenRight_OnExpandedNodeWithNoChildren_IsUnhandled() + { + // Arrange: Specs is expanded with zero children and pending. + var (router, provider) = await RouterAsync(); + Children(provider, Specs); + await ExpandAlphaAsync(router); + var specs = NodeOf(router, 0); + await router.ToggleDescendantAsync( + router.Model.Rows[0].Identity, + specs.Identity, + CancellationToken.None + ); + router.SelectRow(0); + router.OpenSelector(); + router.MoveSelector(previous: false); + + // Act + var outputs = await ArrowAsync(router, "right"); + + // Assert + IsUnhandled(outputs).Should().BeTrue(); + } + + private static bool IsUnhandled(IReadOnlyList outputs) => + BreadcrumbBridgeSerializer.Parse(outputs.Single()) is UnhandledArrowMessage; + + private static async Task<( + FolderBreadcrumbBridgeRouter, + Mock + )> OpenOnAlphaChildAsync(int child) + { + var (router, provider) = await RouterAsync(); + await ExpandAlphaAsync(router); + router.SelectRow(0); + router.OpenSelector(); + for (int step = 0; step <= child; step++) + { + router.MoveSelector(previous: false); + } + return (router, provider); + } + + private static async Task<( + FolderBreadcrumbBridgeRouter, + Mock + )> RouterAsync() + { + var provider = new Mock(MockBehavior.Strict); + Chain( + provider, + Alpha, + Segment(Archive, true), + Segment(Projects, true), + Segment(Alpha, true) + ); + Chain( + provider, + Beta, + Segment(Archive, true), + Segment(Archive + @"\Clients", true), + Segment(Beta, false) + ); + Children(provider, Alpha, Segment(Specs, true), Segment(Alpha + @"\Docs", false)); + Children(provider, Specs, Segment(Specs + @"\Drafts", false)); + Children( + provider, + Projects, + Segment(Alpha, true), + Segment(Projects + @"\Omega", false) + ); + var router = new FolderBreadcrumbBridgeRouter(provider.Object); + await router.SetSuggestionsAsync( + new[] { Row(Alpha, 0.7), Row(Beta, 0.2) }, + CancellationToken.None + ); + return (router, provider); + } + + private static Task> ExpandAlphaAsync( + FolderBreadcrumbBridgeRouter router + ) => + router.RouteAsync( + "{\"type\":\"affordanceToggle\",\"rowIndex\":0}", + CancellationToken.None + ); + + private static Task> ArrowAsync( + FolderBreadcrumbBridgeRouter router, + string direction + ) => + router.RouteAsync( + "{\"type\":\"arrowKey\",\"direction\":\"" + direction + "\"}", + CancellationToken.None + ); + + private static BreadcrumbDescendantNode NodeOf( + FolderBreadcrumbBridgeRouter router, + int index + ) => router.Model.Rows[0].DescendantRoot!.Children[index]; + + private static void Chain( + Mock provider, + string leaf, + params FolderBreadcrumbSegment[] chain + ) + { + provider + .Setup(p => p.ResolveLeafKeyAsync(leaf, It.IsAny())) + .ReturnsAsync(Key(leaf)); + provider + .Setup(p => p.GetAncestorChainAsync(Key(leaf), It.IsAny())) + .ReturnsAsync(chain); + } + + private static void Children( + Mock provider, + string parent, + params FolderBreadcrumbSegment[] children + ) => + provider + .Setup(p => + p.GetImmediateSubfoldersAsync(Key(parent), It.IsAny()) + ) + .ReturnsAsync(children); + + private static FolderRow Row(string path, double probability) => + new FolderRow(path, FolderRowKind.Suggestion, new FolderScore(path, 1000, probability)); + + private static FolderTreeNodeKey Key(string path) => + new FolderTreeNodeKey("store", path, path); + + private static FolderBreadcrumbSegment Segment(string path, bool hasChildren) => + new FolderBreadcrumbSegment( + Key(path), + path.Substring(path.LastIndexOf('\\') + 1), + path, + hasChildren + ); + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs index 9be15ba5f..5723d8720 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs @@ -205,6 +205,10 @@ public async Task Route_AffordanceToggleExpand_QueriesProviderAndReturnsRenderPl ); } + /// + /// #1007 S5: the bridge-channel segment double-click is a logged no-op; the QFC double-click + /// moved to the selector channel so it can commit and close. + /// [TestMethod] public async Task Route_SegmentDoubleClick_ProducesCollapsedRenderPayload() { @@ -217,21 +221,9 @@ public async Task Route_SegmentDoubleClick_ProducesCollapsedRenderPayload() CancellationToken.None ); - // Assert (FR-3): collapsed row renders plus + terminal segment only. - outputs.Should().ContainSingle(); - var render = (RenderMessage)BreadcrumbBridgeSerializer.Parse(outputs[0]); - render.Rows[0].Collapsed.Should().BeTrue(); - render - .Rows[0] - .Cells.Should() - .SatisfyRespectively( - plus => plus.Kind.Should().Be(BreadcrumbCellKind.Plus), - segment => - { - segment.Kind.Should().Be(BreadcrumbCellKind.Segment); - segment.Text.Should().Be("Inbox"); - } - ); + // Assert: no output and no shortening. + outputs.Should().BeEmpty(); + router.Model.Rows[0].CollapsedAfterIndex.Should().BeNull(); } [TestMethod] @@ -364,24 +356,20 @@ public async Task Route_RightArrow_NothingToExpand_ReportsUnhandledRight() .Be(BreadcrumbArrowDirection.Right); } + /// + /// #1007 AC7: the #440 walk is removed, so the first Left on a closed list with nothing + /// expanded has nothing to collapse and is reported unhandled. + /// [TestMethod] public async Task Route_LeftArrow_NothingToCollapse_ReportsUnhandledLeft() { - // Arrange: #440 Left walks the ancestor chain, so on this three-segment fixture two - // presses are needed to reach the root. Only once the root is active does nothing - // remain to collapse and no further tree transition apply. + // Arrange var router = await PopulatedRouterAsync(ProviderMock()); - var firstPress = await ArrowAsync(router, "left"); - var secondPress = await ArrowAsync(router, "left"); // Act var outputs = await ArrowAsync(router, "left"); // Assert - firstPress.Should().ContainSingle(); - BreadcrumbBridgeSerializer.Parse(firstPress[0]).Should().BeOfType(); - secondPress.Should().ContainSingle(); - BreadcrumbBridgeSerializer.Parse(secondPress[0]).Should().BeOfType(); outputs.Should().ContainSingle(); ((UnhandledArrowMessage)BreadcrumbBridgeSerializer.Parse(outputs[0])) .Direction.Should() @@ -440,27 +428,27 @@ string direction ); /// - /// #440 Qfc Left is routed as the parent-select tree transition rather than reported as an - /// unhandled arrow, and the node the router subsequently expands is the parent. + /// #1007 AC7: Qfc Left on a multi-segment row no longer selects the parent; it is reported + /// unhandled, and the following Right still expands the LEAF, not the parent. /// [TestMethod] public async Task ArrowAsync_QfcLeftOnMultiSegmentRow_RoutesParentSelectTransition() { // Arrange - var provider = ParentSubfolderProviderMock(); + var provider = ProviderMock(); var router = await PopulatedRouterAsync(provider); // Act var left = await ArrowAsync(router, "left"); var right = await ArrowAsync(router, "right"); - // Assert: Left produced a render, not an unhandled-arrow fall-through. + // Assert: Left is the unhandled-arrow fall-through. left.Should().ContainSingle(); - BreadcrumbBridgeSerializer.Parse(left[0]).Should().BeOfType(); + BreadcrumbBridgeSerializer.Parse(left[0]).Should().BeOfType(); - // Assert: the expansion that follows is keyed on the selected PARENT node. + // Assert: the expansion that follows is keyed on the leaf. provider.Verify( - p => p.GetImmediateSubfoldersAsync(MidKey, It.IsAny()), + p => p.GetImmediateSubfoldersAsync(LeafKey, It.IsAny()), Times.Once() ); right.Should().HaveCount(2); @@ -471,25 +459,28 @@ public async Task ArrowAsync_QfcLeftOnMultiSegmentRow_RoutesParentSelectTransiti } /// - /// #440 Qfc Right on the already-expanded selected parent node descends into child index 0, - /// which the router reports through the selected-folder value of its render payload. + /// #1007 AC9 (R3): Qfc Right on a row shortened at its active segment (Projects) expands that + /// segment through one query keyed on it, and the row stays shortened. /// [TestMethod] public async Task ArrowAsync_QfcRightOnSelectedParentNode_RoutesChildExpansion() { - // Arrange: select the parent node and expand it. + // Arrange: activate Projects and shorten the row there. var provider = ParentSubfolderProviderMock(); var router = await PopulatedRouterAsync(provider); - await ArrowAsync(router, "left"); - await ArrowAsync(router, "right"); + router.Model.Rows[0].ActivateSegment(1).Should().BeTrue(); + router.Model.Rows[0].CollapseAfter(1); // Act var outputs = await ArrowAsync(router, "right"); // Assert - outputs.Should().ContainSingle(); - BreadcrumbBridgeSerializer.Parse(outputs[0]).Should().BeOfType(); - router.GetSelectedFolder().Should().Be(MidChildPath); + outputs.Should().HaveCount(2); + provider.Verify( + p => p.GetImmediateSubfoldersAsync(MidKey, It.IsAny()), + Times.Once() + ); + router.Model.Rows[0].CollapsedAfterIndex.Should().Be(1); } } } diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderIssue1007Tests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderIssue1007Tests.cs new file mode 100644 index 000000000..35c1ad027 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderIssue1007Tests.cs @@ -0,0 +1,135 @@ +#nullable enable +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Issue #1007 AC14: a mapped segment's HasChildren is derived from the children that + /// resolve in the snapshot (the set returns), not + /// from the raw child-key count. The snapshot is built through its public constructor with a + /// reader-reported child key that has no node, and served through a mocked tree service. + /// + [TestClass] + public sealed class OutlookFolderHierarchyProviderIssue1007Tests + { + private static readonly FolderTreeNodeKey RootKey = Key("root", "\\Root"); + private static readonly FolderTreeNodeKey ClientsKey = Key("clients", "\\Root\\Clients"); + private static readonly FolderTreeNodeKey AcmeKey = Key("acme", "\\Root\\Clients\\Acme"); + + // Reported by the reader as a child of Acme, but absent from the snapshot. + private static readonly FolderTreeNodeKey GhostKey = Key( + "ghost", + "\\Root\\Clients\\Acme\\Ghost" + ); + + /// + /// AC14: the leaf Acme lists only an unresolvable child key, so the ancestor chain reports + /// it without children and no renderer draws an expand affordance on it. + /// + [TestMethod] + public async Task MapNode_UnresolvableChildKey_HasChildrenFalse() + { + // Arrange + var provider = new OutlookFolderHierarchyProvider(Service().Object); + + // Act + var chain = await provider.GetAncestorChainAsync(AcmeKey, CancellationToken.None); + + // Assert + chain.Last().Key.Should().Be(AcmeKey); + chain + .Last() + .HasChildren.Should() + .BeFalse("Acme's only child key does not resolve in the snapshot"); + } + + /// + /// AC14: the immediate-subfolder fetch maps its children through the same derivation, so + /// a child whose child keys all fail to resolve is reported without children. + /// + [TestMethod] + public async Task GetImmediateSubfolders_ChildWithOnlyUnresolvableKeys_HasChildrenFalse() + { + // Arrange + var provider = new OutlookFolderHierarchyProvider(Service().Object); + + // Act + var children = await provider.GetImmediateSubfoldersAsync( + ClientsKey, + CancellationToken.None + ); + + // Assert + children.Should().ContainSingle().Which.Key.Should().Be(AcmeKey); + children[0] + .HasChildren.Should() + .BeFalse("the subfolder fetch for Acme would return zero children"); + } + + /// + /// Guard (AC14): a segment whose child key resolves keeps HasChildren true. + /// + [TestMethod] + public async Task GetAncestorChain_ResolvableChildKey_HasChildrenTrue() + { + // Arrange + var provider = new OutlookFolderHierarchyProvider(Service().Object); + + // Act + var chain = await provider.GetAncestorChainAsync(AcmeKey, CancellationToken.None); + + // Assert + chain.Should().HaveCount(3); + chain[0].HasChildren.Should().BeTrue("Root's child Clients resolves"); + chain[1].HasChildren.Should().BeTrue("Clients' child Acme resolves"); + } + + private static Mock Service() + { + var snapshot = new FolderTreeSnapshot( + new[] { RootKey }, + new[] + { + Node(RootKey, "Root", null, ClientsKey), + Node(ClientsKey, "Clients", RootKey, AcmeKey), + Node(AcmeKey, "Acme", ClientsKey, GhostKey), + } + ); + var service = new Mock(MockBehavior.Strict); + service + .Setup(s => + s.GetSnapshotAsync(It.IsAny(), It.IsAny()) + ) + .ReturnsAsync(snapshot); + return service; + } + + private static FolderTreeNodeKey Key(string entryId, string folderPath) => + new FolderTreeNodeKey("store-a", entryId, folderPath); + + private static FolderTreeSnapshotNode Node( + FolderTreeNodeKey key, + string displayName, + FolderTreeNodeKey? parentKey, + params FolderTreeNodeKey[] childKeys + ) => + new FolderTreeSnapshotNode( + key, + displayName, + key.StoreId, + key.EntryId, + parentKey, + key.FolderPath, + displayName, + childKeys, + false, + string.Empty + ); + } +} diff --git a/UtilitiesCS.Test/UtilitiesCS.Test.csproj b/UtilitiesCS.Test/UtilitiesCS.Test.csproj index 711cee202..3896cf270 100644 --- a/UtilitiesCS.Test/UtilitiesCS.Test.csproj +++ b/UtilitiesCS.Test/UtilitiesCS.Test.csproj @@ -327,6 +327,13 @@ + + + + + + + diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs index c8599ff87..f98bef46f 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs @@ -59,6 +59,16 @@ public static class BreadcrumbDocumentAssets + " var wrap = el.closest('[data-row-id]');\n" + " return wrap ? wrap.getAttribute('data-row-id') : null;\n" + " }\n" + // #1007 S3: rows and descendants fully inside the viewport, at least one. + + " function fullyVisibleCount() {\n" + + " var bottom = window.innerHeight || document.documentElement.clientHeight;\n" + + " var count = 0;\n" + + " document.querySelectorAll('.rowwrap > .row, .child').forEach(function (el) {\n" + + " var r = el.getBoundingClientRect();\n" + + " if (r.top >= 0 && r.bottom <= bottom) { count++; }\n" + + " });\n" + + " return Math.max(1, count);\n" + + " }\n" + " document.addEventListener('dblclick', function (e) {\n" + " var seg = e.target.closest('.seg');\n" + " if (!seg) { return; }\n" @@ -141,12 +151,17 @@ public static class BreadcrumbDocumentAssets + " if (id) { post({ type: 'rowConfirmed', rowId: id }); }\n" + " return;\n" + " }\n" - + " var map = { ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', ArrowDown: 'Down' };\n" + // #1007: Ctrl+ArrowLeft is ParentLeft (S4), Ctrl+ArrowRight stays Right (D-Q20), + // and the page keys carry the measured page size (S3). + + " var map = { ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', ArrowDown: 'Down', Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown' };\n" + " var key = map[e.key];\n" + " if (!key) { return; }\n" + + " if (e.ctrlKey && e.key === 'ArrowLeft') { key = 'ParentLeft'; }\n" + " var selected = document.querySelector('.rowwrap.selected');\n" + " var id = selected ? selected.getAttribute('data-row-id') : '';\n" - + " post({ type: 'arrowKey', rowId: id || '', key: key });\n" + + " var message = { type: 'arrowKey', rowId: id || '', key: key };\n" + + " if (key === 'PageUp' || key === 'PageDown') { message.pageSize = fullyVisibleCount(); }\n" + + " post(message);\n" + " e.preventDefault();\n" + " });\n" + " if (window.chrome && window.chrome.webview) {\n" @@ -176,7 +191,8 @@ public static class BreadcrumbDocumentAssets + " host.appendChild(div);\n" + " });\n" + " }\n" - + " var scrollTarget = document.querySelector('.rowwrap.selected');\n" + // #1007 AC1: keep a selected descendant in view before falling back to its row. + + " var scrollTarget = document.querySelector('.child[aria-selected=\"true\"]') || document.querySelector('.rowwrap.selected');\n" + " if (scrollTarget) { scrollTarget.scrollIntoView({ block: 'nearest' }); }\n" + " });\n" + " }\n" diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs index 107c49743..ebe63b6d8 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs @@ -154,12 +154,15 @@ bool isSelected } bool isCollapsedTerminal = row.IsCollapsed && i == visible.Count - 1; - if (isCollapsedTerminal) + // Plus to the LEFT of the now-terminal segment; #1007 AC9: a row shortened at its + // active segment shows minus there while that segment's children are shown, and + // AC14: nothing after a fetch that returned no child. + bool anchoredOpen = row.IsAnchoredCollapse && row.IsLeafExpanded; + if (isCollapsedTerminal && !(anchoredOpen && row.HasNoLoadedChildren)) { - // Re-expand plus to the LEFT of the now-terminal segment. - sb.Append( - "+" - ); + sb.Append("") + .Append(anchoredOpen ? "−" : "+") + .Append(""); } AppendSegment(sb, visible[i], i, i < row.Segments.Count - 1); @@ -196,7 +199,8 @@ bool isNonLeaf private static void AppendLeafAffordance(StringBuilder sb, BreadcrumbRow row) { // Emitted only when the active segment has subfolders: plus collapsed, minus expanded. - if (row.ActiveSegment?.HasSubfolders != true) + // #1007 AC14: a fetch that stored zero children leaves no affordance. + if (row.ActiveSegment?.HasSubfolders != true || row.HasNoLoadedChildren) { return; } @@ -276,7 +280,8 @@ private static void AppendDescendants( sb.Append(">") .Append(WebUtility.HtmlEncode(node.Segment.DisplayName)) .Append(""); - if (node.Segment.HasChildren) + // #1007 AC14: a loaded node with zero children carries no affordance. + if (node.Segment.HasChildren && !(node.Loaded && node.Children.Count == 0)) { sb.Append( "segmentIndex; renderedChildActivate requires childIndex; /// arrowKey requires key; descendantToggle and /// descendantActivate require a non-empty string nodeId, exposed as - /// ; every type requires rowId). + /// ; every type requires rowId). An + /// optional integer pageSize (#1007) is exposed as + /// ; a non-integer value is rejected. /// /// The raw JSON payload from the hosted document. /// The validated inbound message. @@ -141,7 +143,19 @@ public BreadcrumbInboundMessage DeserializeInbound(string json) } } - return new BreadcrumbInboundMessage(type, rowId, segmentIndex, childIndex, key, nodeId); + // #1007: an optional page size for PageUp and PageDown; a non-integer fails through + // OptionalInt. Range checks belong to the router, which logs and ignores a bad value. + int? pageSize = OptionalInt(root, "pageSize"); + + return new BreadcrumbInboundMessage( + type, + rowId, + segmentIndex, + childIndex, + key, + nodeId, + pageSize + ); } private static bool IsKnownInboundType(string type) diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs index 3e88db8dc..e3a601ef6 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs @@ -71,6 +71,10 @@ public sealed class BreadcrumbInboundMessage /// Host-issued descendant node identity for the descendant toggle and activate messages, /// or null for every other type. Optional so existing call sites compile unchanged. /// + /// + /// Fully visible row count measured by the page for the PageUp and PageDown + /// arrow keys (#1007), or null. Optional so existing call sites compile unchanged. + /// /// or is null. public BreadcrumbInboundMessage( string type, @@ -78,7 +82,8 @@ public BreadcrumbInboundMessage( int? segmentIndex, int? childIndex, string? key, - string? nodeId = null + string? nodeId = null, + int? pageSize = null ) { Type = type ?? throw new ArgumentNullException(nameof(type)); @@ -87,6 +92,7 @@ public BreadcrumbInboundMessage( ChildIndex = childIndex; Key = key; NodeId = nodeId; + PageSize = pageSize; } /// Message discriminator. @@ -109,6 +115,12 @@ public BreadcrumbInboundMessage( /// and ; null otherwise. /// public string? NodeId { get; } + + /// + /// Page size for the PageUp and PageDown arrow keys (#1007); null when the + /// page sent none. The router treats a missing or non-positive value as a logged no-op. + /// + public int? PageSize { get; } } /// diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs index 35d0202b9..c617b01d5 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs @@ -216,9 +216,15 @@ private static BreadcrumbRowRender ProjectRow( } if (row.CollapsedAfterIndex.HasValue && isTerminal) { - // The re-expand plus sits to the left of the now-terminal segment (FR-3). + // The affordance sits to the left of the now-terminal segment (FR-3); while the + // shortened row lists that segment's children it is the collapse minus (#1007 R3). cells.Add( - new BreadcrumbCellRender(BreadcrumbCellKind.Plus, string.Empty, -1, false) + new BreadcrumbCellRender( + row.LeafExpanded ? BreadcrumbCellKind.Minus : BreadcrumbCellKind.Plus, + string.Empty, + -1, + false + ) ); } bool interior = s > 0 && !isTerminal; @@ -227,7 +233,13 @@ private static BreadcrumbRowRender ProjectRow( ); } - if (row.LeafHasSubfolders) + // #1007 AC14: no affordance after a fetch that attached zero children. + bool zeroLoaded = + row.LeafExpanded + && row.DescendantRoot != null + && row.DescendantRoot.Loaded + && row.DescendantRoot.Children.Count == 0; + if (row.LeafHasSubfolders && !zeroLoaded) { // Leaf-only affordance: plus when the expansion is closed, minus when open (FR-2). cells.Add( @@ -270,7 +282,8 @@ private static BreadcrumbSubfolderRender ProjectDescendant( new BreadcrumbSubfolderRender( node.Segment.DisplayName, node.Segment.FolderPath, - node.Segment.HasChildren, + // #1007 AC14: a loaded node with zero children reports no children. + node.Segment.HasChildren && !(node.Loaded && node.Children.Count == 0), node.Identity, node.Expanded, node == selected, diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.Descendants.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.Descendants.cs index 50f845eb0..f9495c5b5 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.Descendants.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.Descendants.cs @@ -46,6 +46,7 @@ public bool SetLeafDescendants(IReadOnlyList children) } _leafChildren = new List(BreadcrumbRowBuilder.MapSegments(children)); + LeafChildrenLoaded = true; var nodes = new List(children.Count); foreach (FolderBreadcrumbSegment child in children) { @@ -174,6 +175,62 @@ public bool TrySelectDescendant(string? nodeId) return true; } + /// + /// Reports whether the selected descendant is a strict descendant of + /// (#1007 R5), that is, whether collapsing that node hides the + /// selection. False for an unknown node or when no descendant is selected. + /// + /// Host-issued node identity. + public bool IsSelectedDescendantWithin(string nodeId) + { + BreadcrumbDescendantNode? node = FindDescendant(nodeId); + if ( + node == null + || SelectedDescendantId == null + || node.Identity == SelectedDescendantId + ) + { + return false; + } + + return node.Find(SelectedDescendantId) != null; + } + + /// + /// Appends this row's visible entries (#1007): nothing for a banner row; otherwise the row + /// entry, then the shown descendant nodes in pre-order. + /// + /// The row's index in its list. + /// The order being built. + /// is null. + public void AppendVisiblePositions(int rowIndex, List target) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (Kind == BreadcrumbRowKind.Banner) + { + return; + } + + target.Add( + new BreadcrumbVisiblePosition( + rowIndex, + null, + null, + -1, + CanExpandActiveSegment() && !HasNoLoadedChildren, + IsLeafExpanded + ) + ); + if (IsLeafExpanded) + { + BreadcrumbVisibleOrder.AppendNodes(rowIndex, _descendants, null, 0, target); + } + } + // Called exactly where the first-level children stop being shown or are replaced. private void ClearDescendants() { diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs index 059f69bae..a83541efc 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs @@ -27,9 +27,9 @@ public enum BreadcrumbRowKind /// /// No-op rules: and /// rows never collapse or expand; a leaf without - /// subfolders is a toggle no-op. Arrow semantics follow the TreeListView parity of the spec — - /// Right expands (restores a collapsed breadcrumb, else expands the leaf children), Left - /// collapses (leaf children first, else hides the trailing segment). + /// subfolders is a toggle no-op. Arrow semantics (#1007): Right expands the active segment + /// (restoring a collapse that hides it first), Left collapses the expanded children and never + /// hides a segment. /// public sealed partial class BreadcrumbRow { @@ -37,6 +37,7 @@ public sealed partial class BreadcrumbRow private List _leafChildren = new List(); private readonly Dictionary _segmentKeys = new Dictionary(); + private bool _leafFetchInFlight; /// /// Creates a breadcrumb row. @@ -120,6 +121,15 @@ public BreadcrumbRow( /// Immediate subfolders of the leaf, populated via . public IReadOnlyList LeafChildren => _leafChildren; + /// + /// True once a fetch stored the active segment's children (#1007 AC14), so an empty + /// means "no children" rather than "not fetched yet". + /// + internal bool LeafChildrenLoaded { get; private set; } + + /// True when a fetch stored zero children for the active segment (#1007 AC14). + internal bool HasNoLoadedChildren => LeafChildrenLoaded && _leafChildren.Count == 0; + /// The anchored predicted leaf segment, or null when the row has no segments. public BreadcrumbSegment? LeafSegment => _segments.Count > 0 ? _segments[_segments.Count - 1] : null; @@ -167,6 +177,7 @@ public bool ActivateSegment(int segmentIndex) ActiveSegmentIndex = segmentIndex; _leafChildren = new List(); + LeafChildrenLoaded = false; ClearDescendants(); IsLeafExpanded = false; return true; @@ -265,18 +276,20 @@ public bool SetLeafChildren(IEnumerable children) } _leafChildren = new List(children); + LeafChildrenLoaded = true; ClearDescendants(); // Key-less children carry no expandable descendants. return true; } /// - /// Toggles the leaf expand state. No-op for banner/pseudo rows, for a leaf without - /// subfolders, and while the breadcrumb is collapsed (the leaf is hidden). + /// Toggles the active segment's expand state. No-op for banner/pseudo rows, for a segment + /// without subfolders, and while a collapse hides the active segment. A row shortened at + /// its active segment (#1007 R3) can expand that segment. /// /// True when the view state changed. public bool ToggleLeafExpanded() { - if (!CanExpandActiveSegment() || IsCollapsed) + if (!CanExpandActiveSegment()) { return false; } @@ -291,39 +304,27 @@ public bool ToggleLeafExpanded() } /// - /// Left-arrow transition: collapses expanded leaf children first; otherwise hides the - /// trailing segment (collapse-after the previous segment). No-op for banner/pseudo rows, - /// single-segment rows, and rows already collapsed at the root segment. + /// Left-arrow transition: collapses the expanded children. Left never hides a segment + /// (#1007 AC7), so a row without an expansion is a no-op, as are banner/pseudo rows. /// /// True when the view state changed. public bool LeftArrow() { - if (Kind != BreadcrumbRowKind.Suggestion || _segments.Count == 0) + if (Kind != BreadcrumbRowKind.Suggestion || !IsLeafExpanded) { return false; } - if (IsLeafExpanded) - { - IsLeafExpanded = false; - ClearDescendants(); - return true; - } - - int terminalIndex = CollapsedAfterIndex ?? (_segments.Count - 1); - if (terminalIndex == 0) - { - return false; // Only the root segment remains visible. - } - - CollapsedAfterIndex = terminalIndex - 1; + IsLeafExpanded = false; + ClearDescendants(); return true; } /// - /// Right-arrow transition: restores the full breadcrumb when collapsed; otherwise expands - /// the leaf children when the leaf has subfolders. No-op for banner/pseudo rows, for a - /// leaf without subfolders, and when already fully expanded. + /// Right-arrow transition: restores a collapse that hides the active segment; otherwise + /// expands the active segment when it has subfolders, including on a row shortened at its + /// active segment (#1007 R3). No-op for banner/pseudo rows, a segment without subfolders, + /// and an expanded row. /// /// True when the view state changed. public bool RightArrow() @@ -333,20 +334,56 @@ public bool RightArrow() return false; } - if (IsCollapsed) + if (IsCollapsed && !IsAnchoredCollapse) { return ReExpand(); } - if (CanExpandActiveSegment() && !IsLeafExpanded) + return ExpandLeaf(); + } + + /// + /// Idempotent expand-to-true of the active segment (#1007 R4): false when already + /// expanded or when the segment cannot expand, so overlapping completions cannot toggle + /// the row back to collapsed. + /// + /// True when the view state changed. + public bool ExpandLeaf() + { + if (!CanExpandActiveSegment() || IsLeafExpanded) + { + return false; + } + + IsLeafExpanded = true; + return true; + } + + /// + /// Starts the single in-flight fetch of the active segment's children (#1007 S7). + /// + /// False while another fetch for this row is in flight. + public bool TryBeginLeafFetch() + { + if (_leafFetchInFlight) { - IsLeafExpanded = true; - return true; + return false; } - return false; + _leafFetchInFlight = true; + return true; } + /// Ends the fetch started by . + public void EndLeafFetch() => _leafFetchInFlight = false; + + /// + /// True when the row is shortened at its active segment (#1007 R3), as a segment + /// double-click or Ctrl+Left leaves it; the active segment is then the visible terminal. + /// + public bool IsAnchoredCollapse => + CollapsedAfterIndex.HasValue && CollapsedAfterIndex == ActiveSegmentIndex; + /// /// Projects the currently visible segments: all segments when fully expanded, or the /// segments up to and including when collapsed. @@ -362,9 +399,12 @@ public IReadOnlyList VisibleSegments() return _segments.GetRange(0, CollapsedAfterIndex.Value + 1); } + // R3: a collapse hides the active segment unless it is anchored at that segment. private bool CanExpandActiveSegment() { - return Kind == BreadcrumbRowKind.Suggestion && ActiveSegment?.HasSubfolders == true; + return Kind == BreadcrumbRowKind.Suggestion + && ActiveSegment?.HasSubfolders == true + && (!IsCollapsed || IsAnchoredCollapse); } } } diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs index 5599754d6..21f1a4659 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs @@ -34,16 +34,40 @@ public static class BreadcrumbSelectionMap ? value : null; } - return RowValue(row); + return SelectedRowValue(model, row); + } + + /// + /// The filing value of the selected row (#1007 S9): the active-segment folder for a + /// shortened or parent-activated suggestion row, converted under the same archive-root rule + /// as a descendant, or null when that folder is not a valid filing target; otherwise the + /// row's . + /// + internal static string? SelectedRowValue(BreadcrumbStateModel model, BreadcrumbStateRow row) + { + int? active = row.ActiveSegmentIndex; + if (!active.HasValue || active.Value >= row.Chain.Count - 1) + { + return RowValue(row); + } + return TryFolderValue(model, row.Chain[active.Value].FolderPath, out string value) + ? value + : null; } internal static bool TryDescendantValue( BreadcrumbStateModel model, BreadcrumbDescendantNode node, out string value + ) => TryFolderValue(model, node.Segment.FolderPath, out value); + + private static bool TryFolderValue( + BreadcrumbStateModel model, + string folderPath, + out string value ) { - value = node.Segment.FolderPath; + value = folderPath; if (model.ArchiveRoot == null) { return true; diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.Tree.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.Tree.cs new file mode 100644 index 000000000..cbc9d96cb --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.Tree.cs @@ -0,0 +1,363 @@ +#nullable enable +using System.Collections.Generic; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Issue #1007: open-list navigation over the visible order (rows plus shown descendants), the + /// pending descendant node, boundary, page and parent moves, and the row-shape snapshot. + /// While the list is open every move changes only and + /// ; the committed model selection is untouched until + /// (AC4). + /// + internal sealed partial class BreadcrumbSelectionSession + { + private readonly List<(BreadcrumbStateRow Row, int? Active, int? Collapsed)> _openShape = + new List<(BreadcrumbStateRow Row, int? Active, int? Collapsed)>(); + + /// The pending descendant node on the pending row, or null for a row entry. + public BreadcrumbDescendantNode? PendingNode { get; private set; } + + /// + /// Moves the open-list pending highlight to the first or last visible entry (AC11). + /// + /// True when the pending entry changed; false when closed or already there. + public bool MoveToBoundary(bool first) + { + if (!IsOpen) + { + return false; + } + var order = _model.VisiblePositions(); + int target = first + ? BreadcrumbVisibleOrder.First(order) + : BreadcrumbVisibleOrder.Last(order); + return target >= 0 && target != PendingPosition(order) && Pend(order[target]); + } + + /// + /// Moves the open-list pending highlight by visible entries, + /// clamped at both ends without wrap (AC11, S3). + /// + /// False when closed, when is below 1, or at the end. + public bool MovePage(bool previous, int pageSize) + { + if (!IsOpen || pageSize < 1) + { + return false; + } + return MoveOpen(previous ? -pageSize : pageSize); + } + + /// + /// Ctrl+Left (AC8, D-Q19). Open: a pending node pends its parent entry and keeps the + /// expansion; a pending row is shortened at its parent segment, pending only, and Escape + /// restores the shape (D-Q24). Closed: the parent is committed immediately. Active index 0 + /// is a no-op. + /// + public bool MoveToParent() + { + if (!IsOpen) + { + return CommitParent(); + } + var order = _model.VisiblePositions(); + int current = PendingPosition(order); + if (current < 0) + { + return false; + } + if (!order[current].IsRowEntry) + { + return Pend(order[BreadcrumbVisibleOrder.ParentOf(order, current)]); + } + BreadcrumbStateRow row = _model.Rows[order[current].RowIndex]; + int? active = row.ActiveSegmentIndex; + if (!active.HasValue || !row.ActivateSegment(active.Value - 1)) + { + return false; + } + row.CollapseAfter(active.Value - 1); + return true; + } + + /// + /// Commits the first or last selectable top-level row of a closed session (AC10, D-Q16). + /// + /// False when open, when no row is selectable, or when that row entry is already committed. + public bool CommitBoundaryRow(bool first) + { + int target = first ? FirstSelectableIndex() : LastSelectableIndex(); + if ( + IsOpen + || target < 0 + || (target == _model.SelectedIndex && _model.SelectedDescendant == null) + ) + { + return false; + } + _model.SelectRow(target); + CommittedIdentity = _model.Rows[target].Identity; + return true; + } + + /// + /// Closed-list Ctrl+Left (D-Q19): commits the parent immediately. A committed node commits + /// its parent node, or the row entry at depth 0, keeping the expansion; a committed row + /// entry activates and commits its parent segment and is shortened there. + /// + private bool CommitParent() + { + int rowIndex = _model.SelectedIndex; + BreadcrumbStateRow? row = rowIndex < 0 ? null : _model.Rows[rowIndex]; + var node = _model.SelectedDescendant; + var path = new List(); + if (row == null || (node != null && !FindPath(row.DescendantRoot!, node, path))) + { + return false; + } + if (node != null) + { + var parent = path.Count > 1 ? path[path.Count - 2] : null; + if (parent == null) + { + _model.SelectRow(rowIndex); + } + else if (BreadcrumbSelectionMap.TryDescendantValue(_model, parent, out _)) + { + _model.SelectDescendant(rowIndex, parent); + } + return parent == null || ReferenceEquals(_model.SelectedDescendant, parent); + } + int? active = row.ActiveSegmentIndex; + if (!active.HasValue || !row.ActivateSegment(active.Value - 1)) + { + return false; + } + row.CollapseAfter(active.Value - 1); + return true; + } + + /// + /// Selector segment double-click (AC9, D-Q13, D-Q27, S5): activates the segment when it is + /// not already active, commits the row so it files to that segment's folder (S9), shortens + /// the row there, and closes an open session. The leaf and out-of-range indices, plain rows + /// and unknown identities are no-ops. + /// + internal BreadcrumbSelectionEffects DoubleClickSegment(string rowIdentity, int segmentIndex) + { + int rowIndex = IndexOfSelectable(rowIdentity); + BreadcrumbStateRow? row = rowIndex < 0 ? null : _model.Rows[rowIndex]; + if ( + row == null + || !row.IsSuggestion + || segmentIndex < 0 + || segmentIndex >= row.Chain.Count - 1 + ) + { + return BreadcrumbSelectionEffects.None; + } + bool wasOpen = IsOpen; + row.ActivateSegment(segmentIndex); + row.CollapseAfter(segmentIndex); + _model.SelectRow(rowIndex); + CommittedIdentity = row.Identity; + if (wasOpen) + { + EndOpenSession(); + } + return BreadcrumbSelectionEffects.Handled + | BreadcrumbSelectionEffects.SelectionChanged + | BreadcrumbSelectionEffects.RenderRequired + | ( + wasOpen + ? BreadcrumbSelectionEffects.OpenStateChanged + : BreadcrumbSelectionEffects.None + ); + } + + /// + /// Open-list single click on a non-leaf segment (D-Q27): moves only the pending highlight to + /// the row; a closed session or an unknown identity is a no-op. + /// + internal BreadcrumbSelectionEffects HighlightIdentity(string identity) + { + int index = IndexOfSelectable(identity); + return index < 0 ? BreadcrumbSelectionEffects.None : HighlightRow(index); + } + + /// + /// Moves a pending node hidden by a collapse to its nearest visible ancestor, or to the row + /// entry when the row's own expansion closed (AC7, D-Q11). + /// + internal void ReconcilePendingNode() + { + var node = PendingNode; + int rowIndex = IndexOfSelectable(PendingIdentity); + if (node == null) + { + return; + } + BreadcrumbStateRow? row = rowIndex < 0 ? null : _model.Rows[rowIndex]; + var path = new List(); + if (row == null || !row.LeafExpanded || !FindPath(row.DescendantRoot!, node, path)) + { + PendingNode = null; + return; + } + foreach (BreadcrumbDescendantNode ancestor in path) + { + if (ancestor != node && !ancestor.Expanded) + { + PendingNode = ancestor; + return; + } + } + } + + private bool MoveOpen(int delta) + { + var order = _model.VisiblePositions(); + int current = PendingPosition(order); + int target = + current >= 0 ? BreadcrumbVisibleOrder.Step(order, current, delta) + : delta > 0 ? BreadcrumbVisibleOrder.First(order) + : BreadcrumbVisibleOrder.Last(order); + return target >= 0 && Pend(order[target]); + } + + /// Visible-order index of the pending entry; a hidden node falls back to its row. + internal int PendingPosition(IReadOnlyList order) + { + int rowIndex = IndexOfSelectable(PendingIdentity); + if (rowIndex < 0) + { + return -1; + } + int index = BreadcrumbVisibleOrder.IndexOf(order, rowIndex, PendingNode?.Identity); + return index >= 0 ? index : BreadcrumbVisibleOrder.IndexOf(order, rowIndex, null); + } + + /// Moves the pending entry to ; always true. + internal bool Pend(BreadcrumbVisiblePosition position) + { + BreadcrumbStateRow row = _model.Rows[position.RowIndex]; + PendingIdentity = row.Identity; + PendingNode = + position.NodeId == null ? null : row.DescendantRoot?.Find(position.NodeId); + return true; + } + + /// Starts open-list state: the row-shape snapshot and the D-Q29 pending node. + private void BeginOpenTree() + { + _openShape.Clear(); + foreach (BreadcrumbStateRow row in _model.Rows) + { + if (row.IsSuggestion) + { + _openShape.Add((row, row.ActiveSegmentIndex, row.CollapsedAfterIndex)); + } + } + var descendant = _model.SelectedDescendant; + bool visible = + descendant != null + && BreadcrumbVisibleOrder.IndexOf( + _model.VisiblePositions(), + _model.SelectedIndex, + descendant.Identity + ) >= 0; + PendingNode = visible ? descendant : null; + } + + /// Restores every row whose shape an open-list Ctrl+Left changed (D-Q24). + private void RestoreOpenShape() + { + foreach (var (row, active, collapsed) in _openShape) + { + if (row.ActiveSegmentIndex == active && row.CollapsedAfterIndex == collapsed) + { + continue; + } + // Reset is the only way back to the leaf-anchored state; the expansion it drops was + // already closed by the segment activation that changed the shape. + row.Reset(); + if (active.HasValue && active.Value < row.Chain.Count - 1) + { + row.ActivateSegment(active.Value); + } + if (collapsed.HasValue) + { + row.CollapseAfter(collapsed.Value); + } + } + } + + private void EndOpenTree() + { + PendingNode = null; + _openShape.Clear(); + } + + /// + /// Commits the pending node as the model's descendant selection (AC4). + /// + /// True when the committed descendant changed. + private bool CommitPendingNode(int rowIndex) + { + var node = PendingNode!; + var root = _model.Rows[rowIndex].DescendantRoot; + if ( + root?.Find(node.Identity) != node + || !BreadcrumbSelectionMap.TryDescendantValue(_model, node, out _) + ) + { + return false; + } + if ( + _model.SelectedIndex == rowIndex + && ReferenceEquals(_model.SelectedDescendant, node) + ) + { + return false; + } + int legacyIndex = -1; + for (int index = 0; index < root.Children.Count; index++) + { + legacyIndex = ReferenceEquals(root.Children[index], node) ? index : legacyIndex; + } + _model.SelectDescendant(rowIndex, node, legacyIndex); + return true; + } + + /// True when the committed descendant exists but is not in the visible order. + private bool IsCommittedDescendantHidden() + { + var descendant = _model.SelectedDescendant; + return descendant != null + && BreadcrumbVisibleOrder.IndexOf( + _model.VisiblePositions(), + _model.SelectedIndex, + descendant.Identity + ) < 0; + } + + private static bool FindPath( + BreadcrumbDescendantNode current, + BreadcrumbDescendantNode target, + List path + ) + { + foreach (BreadcrumbDescendantNode child in current.Children) + { + path.Add(child); + if (child == target || FindPath(child, target, path)) + { + return true; + } + path.RemoveAt(path.Count - 1); + } + return false; + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs index f2759ca0f..c7bf27917 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs @@ -24,12 +24,14 @@ internal BreadcrumbSelectorState( bool isOpen, string? committedIdentity, string? pendingIdentity, - IReadOnlyList options + IReadOnlyList options, + string? pendingNodeIdentity = null ) { IsOpen = isOpen; CommittedIdentity = committedIdentity; PendingIdentity = pendingIdentity; + PendingNodeIdentity = pendingNodeIdentity; var copy = new BreadcrumbSelectorOptionState[options.Count]; for (int index = 0; index < options.Count; index++) { @@ -41,6 +43,9 @@ IReadOnlyList options public bool IsOpen { get; } public string? CommittedIdentity { get; } public string? PendingIdentity { get; } + + /// Pending descendant node identity on the pending row (#1007), or null. + public string? PendingNodeIdentity { get; } public IReadOnlyList Options { get; } } @@ -100,8 +105,21 @@ public BreadcrumbSelectionSession(BreadcrumbStateModel model) /// The committed identity captured when the current open session began. public string? OriginalIdentity { get; private set; } - /// The stable identity active in the open selector. - public string? PendingIdentity { get; private set; } + private string? _pendingIdentity; + + /// + /// The stable identity active in the open selector. Assigning it moves the pending entry to + /// that row entry, so it also clears (#1007). + /// + public string? PendingIdentity + { + get => _pendingIdentity; + private set + { + _pendingIdentity = value; + PendingNode = null; + } + } /// True while an open selection session is active. public bool IsOpen { get; private set; } @@ -145,6 +163,7 @@ public void ReconcileRowsReplaced() { PendingIdentity = CommittedIdentity; } + ReconcilePendingNode(); } /// Captures immutable selector state from the current model. @@ -156,7 +175,13 @@ public BreadcrumbSelectorState Snapshot() BreadcrumbStateRow row = _model.Rows[index]; options[index] = new BreadcrumbSelectorOptionState(row.Identity, row.IsSelectable); } - return new BreadcrumbSelectorState(IsOpen, CommittedIdentity, PendingIdentity, options); + return new BreadcrumbSelectorState( + IsOpen, + CommittedIdentity, + PendingIdentity, + options, + PendingNode?.Identity + ); } public BreadcrumbSelectionEffects ClearSelector() @@ -281,6 +306,7 @@ public bool Open() CaptureDescendantSelection(); PendingIdentity = CommittedIdentity; IsOpen = true; + BeginOpenTree(); return true; } @@ -308,7 +334,21 @@ public bool CommitPending() _model.Rows[index].Identity, StringComparison.Ordinal ); - if (_model.SelectedIndex != index || _model.SelectedDescendant == null) + if (PendingNode != null && CommitPendingNode(index)) + { + changed = true; + } + else if ( + PendingNode == null + && _model.SelectedIndex == index + && !IsCommittedDescendantHidden() + ) + { + // #1007 AC4: the pending row entry replaces a visible committed descendant. + changed |= _model.SelectedDescendant != null; + _model.SelectRow(index); + } + else if (_model.SelectedIndex != index) { _model.SelectRow(index); } @@ -353,6 +393,7 @@ public bool Cancel() return false; } + RestoreOpenShape(); int originalIndex = IndexOfSelectable(OriginalIdentity); if (originalIndex >= 0) { @@ -371,6 +412,10 @@ public bool Cancel() private bool Move(int step) { + if (IsOpen) + { + return MoveOpen(step); + } string? identity = IsOpen ? PendingIdentity : CommittedIdentity; int current = IndexOfSelectable(identity); int candidate; @@ -441,6 +486,7 @@ private void EndOpenSession() IsOpen = false; OriginalIdentity = null; PendingIdentity = null; + EndOpenTree(); } } } diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectorMessages.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectorMessages.cs index 0696945cd..205569d5f 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectorMessages.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectorMessages.cs @@ -19,6 +19,13 @@ public enum BreadcrumbSelectorKey Down, Enter, Escape, + + /// Ctrl+Left: move to the parent folder (#1007). + Parent, + Home, + End, + PageUp, + PageDown, } /// Base type for issue #400 selector messages. @@ -35,12 +42,24 @@ public BreadcrumbSelectorViewMessage( bool isOpen, string? committedIdentity, string? pendingIdentity + ) + : this(mode, isOpen, committedIdentity, pendingIdentity, null) { } + + // An overload rather than an optional parameter, so reflective construction with the + // original four arguments still binds (#1007). + public BreadcrumbSelectorViewMessage( + BreadcrumbSelectorViewMode mode, + bool isOpen, + string? committedIdentity, + string? pendingIdentity, + string? pendingNodeIdentity ) { Mode = mode; IsOpen = isOpen; CommittedIdentity = OptionalIdentity(committedIdentity); PendingIdentity = OptionalIdentity(pendingIdentity); + PendingNodeIdentity = OptionalIdentity(pendingNodeIdentity); } public override string Type => "selectorView"; @@ -49,6 +68,9 @@ public BreadcrumbSelectorViewMessage( public string? CommittedIdentity { get; } public string? PendingIdentity { get; } + /// Pending descendant node identity on the pending row (#1007), or null. + public string? PendingNodeIdentity { get; } + private static string? OptionalIdentity(string? identity) { if (identity != null && string.IsNullOrWhiteSpace(identity)) @@ -69,12 +91,60 @@ public sealed class BreadcrumbSelectorToggleMessage : BreadcrumbSelectorMessage public sealed class BreadcrumbSelectorKeyMessage : BreadcrumbSelectorMessage { public BreadcrumbSelectorKeyMessage(BreadcrumbSelectorKey key) + : this(key, null) { } + + public BreadcrumbSelectorKeyMessage(BreadcrumbSelectorKey key, int? pageSize) { Key = key; + PageSize = pageSize; } public override string Type => "selectorKey"; public BreadcrumbSelectorKey Key { get; } + + /// Fully visible row count for PageUp and PageDown (#1007), or null. + public int? PageSize { get; } + } + + /// Page-to-host double-click on a breadcrumb segment of a row (#1007, S5). + public sealed class BreadcrumbSelectorSegmentDoubleClickMessage : BreadcrumbSelectorMessage + { + /// is blank. + /// is negative. + public BreadcrumbSelectorSegmentDoubleClickMessage(string rowIdentity, int segmentIndex) + { + if (string.IsNullOrWhiteSpace(rowIdentity)) + { + throw new ArgumentException("A row identity is required.", nameof(rowIdentity)); + } + if (segmentIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(segmentIndex), segmentIndex, null); + } + RowIdentity = rowIdentity; + SegmentIndex = segmentIndex; + } + + public override string Type => "selectorSegmentDoubleClick"; + public string RowIdentity { get; } + public int SegmentIndex { get; } + } + + /// Page-to-host move of the open-list pending highlight to a row (#1007, AC9). + public sealed class BreadcrumbSelectorHighlightMessage : BreadcrumbSelectorMessage + { + /// is blank. + public BreadcrumbSelectorHighlightMessage(string identity) + { + if (string.IsNullOrWhiteSpace(identity)) + { + throw new ArgumentException("A row identity is required.", nameof(identity)); + } + Identity = identity; + } + + public override string Type => "selectorHighlight"; + public string Identity { get; } } /// Page-to-host activation of a row by stable identity. @@ -193,12 +263,16 @@ public static BreadcrumbSelectorMessage Parse(string json) "Selector message is missing the 'isOpen' field." ), OptionalString(root, "committedIdentity"), - OptionalString(root, "pendingIdentity") + OptionalString(root, "pendingIdentity"), + OptionalString(root, "pendingNodeIdentity") ); case "selectorToggle": return new BreadcrumbSelectorToggleMessage(); case "selectorKey": - return new BreadcrumbSelectorKeyMessage(ParseKey(RequireString(root, "key"))); + return new BreadcrumbSelectorKeyMessage( + ParseKey(RequireString(root, "key")), + OptionalNonNegativeInt(root, "pageSize") + ); case "selectorActivate": return new BreadcrumbSelectorActivationMessage( RequireIdentity(root, "identity") @@ -208,6 +282,15 @@ public static BreadcrumbSelectorMessage Parse(string json) RequireIdentity(root, "rowIdentity"), RequireNonNegativeInt(root, "subfolderIndex") ); + case "selectorSegmentDoubleClick": + return new BreadcrumbSelectorSegmentDoubleClickMessage( + RequireIdentity(root, "rowIdentity"), + RequireNonNegativeInt(root, "segmentIndex") + ); + case "selectorHighlight": + return new BreadcrumbSelectorHighlightMessage( + RequireIdentity(root, "identity") + ); case "selectorDescendantActivate": case "selectorDescendantToggle": return new BreadcrumbSelectorDescendantMessage( @@ -238,11 +321,16 @@ public static string Serialize(BreadcrumbSelectorMessage message) root["isOpen"] = view.IsOpen; AddOptional(root, "committedIdentity", view.CommittedIdentity); AddOptional(root, "pendingIdentity", view.PendingIdentity); + AddOptional(root, "pendingNodeIdentity", view.PendingNodeIdentity); break; case BreadcrumbSelectorToggleMessage _: break; case BreadcrumbSelectorKeyMessage key: root["key"] = key.Key.ToString().ToLowerInvariant(); + if (key.PageSize.HasValue) + { + root["pageSize"] = key.PageSize.Value; + } break; case BreadcrumbSelectorActivationMessage activation: root["identity"] = activation.Identity; @@ -251,6 +339,13 @@ public static string Serialize(BreadcrumbSelectorMessage message) root["rowIdentity"] = subfolderActivation.RowIdentity; root["subfolderIndex"] = subfolderActivation.SubfolderIndex; break; + case BreadcrumbSelectorSegmentDoubleClickMessage doubleClick: + root["rowIdentity"] = doubleClick.RowIdentity; + root["segmentIndex"] = doubleClick.SegmentIndex; + break; + case BreadcrumbSelectorHighlightMessage highlight: + root["identity"] = highlight.Identity; + break; case BreadcrumbSelectorDescendantMessage descendant: root["rowIdentity"] = descendant.RowIdentity; root["nodeIdentity"] = descendant.NodeIdentity; @@ -276,23 +371,33 @@ private static BreadcrumbSelectorViewMode ParseMode(string mode) } } + // The wire value of every key is its lowercase enum name ("pageup", "parent", ...), the + // same spelling Serialize writes, so parsing and serialization cannot drift (#1007). private static BreadcrumbSelectorKey ParseKey(string key) { - switch (key) + foreach ( + BreadcrumbSelectorKey candidate in Enum.GetValues(typeof(BreadcrumbSelectorKey)) + ) { - case "up": - return BreadcrumbSelectorKey.Up; - case "down": - return BreadcrumbSelectorKey.Down; - case "enter": - return BreadcrumbSelectorKey.Enter; - case "escape": - return BreadcrumbSelectorKey.Escape; - default: - throw new FormatException($"Unknown selector key '{key}'."); + if ( + string.Equals( + candidate.ToString().ToLowerInvariant(), + key, + StringComparison.Ordinal + ) + ) + { + return candidate; + } } + throw new FormatException($"Unknown selector key '{key}'."); } + private static int? OptionalNonNegativeInt(JObject root, string name) => + root[name] == null || root[name]!.Type == JTokenType.Null + ? (int?)null + : RequireNonNegativeInt(root, name); + private static string RequireIdentity(JObject root, string name) { string identity = RequireString(root, name); diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.Row.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.Row.cs index 659772cda..7a250aeed 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.Row.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.Row.cs @@ -197,10 +197,13 @@ bool isScoredFallback /// /// True when the ACTIVE node carries the expansion affordance. Identical to - /// while the row is leaf-anchored (#440). + /// while the row is leaf-anchored (#440); a row shortened at + /// its active segment keeps the affordance (#1007 R3). /// public bool ActiveSegmentHasSubfolders => - IsSuggestion && CollapsedAfterIndex == null && ActiveSegment?.HasChildren == true; + IsSuggestion + && (CollapsedAfterIndex == null || CollapsedAfterIndex == ActiveSegmentIndex) + && ActiveSegment?.HasChildren == true; /// /// Selects the non-leaf node at (#440). Refuses plain diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs index ea97e079f..704b87d72 100644 --- a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs @@ -129,6 +129,40 @@ public void AddPlainRow(string identity, string verbatimText, bool isSelectable) private string UniqueIdentity(string identity) => BreadcrumbRowIdentity.Disambiguate(identity, _rows); + /// + /// The visible order (#1007): each selectable row entry followed by its shown descendant + /// nodes in pre-order. + /// + internal List VisiblePositions() + { + var order = new List(); + for (int index = 0; index < _rows.Count; index++) + { + BreadcrumbStateRow row = _rows[index]; + if (!row.IsSelectable) + { + continue; + } + var root = row.LeafExpanded ? row.DescendantRoot : null; + bool zeroLoaded = root != null && root.Loaded && root.Children.Count == 0; + order.Add( + new BreadcrumbVisiblePosition( + index, + null, + null, + -1, + row.ActiveSegmentHasSubfolders && !zeroLoaded, + row.LeafExpanded + ) + ); + if (root != null) + { + BreadcrumbVisibleOrder.AppendNodes(index, root.Children, null, 0, order); + } + } + return order; + } + /// /// Selects the row at (or -1 to clear the selection) and resets any /// subfolder selection. @@ -178,10 +212,12 @@ public void SelectSubfolder(int subfolderIndex) } /// - /// Right-arrow transition on the selected row: re-expands a collapsed chain, else opens the - /// leaf expansion when the affordance is available. Returns false when nothing changed so the - /// caller can report an unhandled arrow (FR-6 legacy fall-through). A successful leaf - /// expansion still requires the caller to fetch and attach subfolders. + /// Right-arrow transition on the selected row (#1007, D-Q4): expands the active segment + /// when its affordance is available. A row shortened at its active segment keeps its + /// shape and lists that segment's children (R3, D-Q23); a collapse anchored elsewhere is + /// restored as before. Returns false when nothing changed so the caller can report an + /// unhandled arrow (FR-6 legacy fall-through). A successful expansion still requires the + /// caller to fetch and attach subfolders. /// public bool RightArrow() { @@ -190,79 +226,39 @@ public bool RightArrow() { return false; } - // #440: attempt the tree transition first (decision D1 handling order). - if (TryRightTreeTransition(row)) - { - return true; - } - if (row.CollapsedAfterIndex != null) - { - row.ReExpand(); - return true; - } - return row.TryExpandLeaf(); + return Expand(row); } - /// - /// #440 Right tree transition. Available only once a non-leaf node has been selected: - /// expands that node when its expansion is closed, and otherwise descends into child - /// index 0 of the fetched subfolders. Returns false when no transition applies, so the - /// caller falls through to the pre-existing behavior. - /// - private bool TryRightTreeTransition(BreadcrumbStateRow row) + /// Applies the Right expansion rule to (#1007 R3). + internal static bool Expand(BreadcrumbStateRow row) { - int? activeIndex = row.ActiveSegmentIndex; - if (!activeIndex.HasValue || activeIndex.Value >= row.Chain.Count - 1) - { - return false; // Leaf-anchored: no node has been selected yet. - } - if (!row.LeafExpanded) + if ( + row.CollapsedAfterIndex != null + && row.CollapsedAfterIndex != row.ActiveSegmentIndex + ) { - // The collapse is cleared as PART of the transition, because the expansion of the - // selected node is not visible while downstream segments are hidden. row.ReExpand(); - return row.TryExpandActiveSegment(); - } - if (row.GetActiveChild(0) == null) - { - return false; // Nothing to descend into. + return true; } - - SelectSubfolder(0); - return true; + return row.TryExpandActiveSegment(); } /// - /// Left-arrow transition on the selected row: closes an open leaf expansion. Returns false - /// when nothing changed so the caller can report an unhandled arrow (FR-6 legacy fall-through). + /// Left-arrow transition on the selected row: closes an open expansion and never selects a + /// parent (#1007 AC7, D-Q9). A subfolder selection hidden by the collapse moves to its + /// nearest visible ancestor, the row itself. Returns false when nothing changed so the + /// caller can report an unhandled arrow (FR-6 legacy fall-through). /// public bool LeftArrow() { var row = SelectedRow; - if (row == null) + if (row == null || !row.TryCollapseLeaf()) { return false; } - // #440: attempt the tree transition first (decision D1 handling order). Each press - // selects the parent of the currently active node, so repeated Left walks the ancestor - // chain to the root. No index test is needed here: ActivateSegment refuses a negative - // index, which is what preserves the root boundary and the pre-existing fall-through. - // While a child of the open expansion is selected, no parent-select is available. - int? activeIndex = row.ActiveSegmentIndex; - if ( - _selectedSubfolderIndex < 0 - && activeIndex.HasValue - && row.ActivateSegment(activeIndex.Value - 1) - ) - { - return true; - } - if (_selectedSubfolderIndex >= 0) - { - _selectedSubfolderIndex = -1; - SelectedDescendant = null; - } - return row.TryCollapseLeaf(); + _selectedSubfolderIndex = -1; + SelectedDescendant = null; + return true; } } } diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbVisibleOrder.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbVisibleOrder.cs new file mode 100644 index 000000000..3c13da026 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbVisibleOrder.cs @@ -0,0 +1,170 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// One entry of the visible order (#1007): a top-level row entry, or a shown descendant node + /// of a row. Immutable; a plain sealed class because net48 has no init accessor. + /// + public sealed class BreadcrumbVisiblePosition + { + /// Creates a visible position. + /// Index of the owning row in its row list. + /// Host-issued node identity, or null for a row entry. + /// Identity of the parent node, or null at depth 0 and for a row. + /// -1 for a row entry, 0 for a first-level node. + /// True when the entry can show children. + /// True when the entry's children are shown. + public BreadcrumbVisiblePosition( + int rowIndex, + string? nodeId, + string? parentNodeId, + int depth, + bool hasChildren, + bool isExpanded + ) + { + RowIndex = rowIndex; + NodeId = nodeId; + ParentNodeId = parentNodeId; + Depth = depth; + HasChildren = hasChildren; + IsExpanded = isExpanded; + } + + /// Index of the owning row. + public int RowIndex { get; } + + /// Node identity, or null for a row entry. + public string? NodeId { get; } + + /// Parent node identity, or null for a first-level node or a row entry. + public string? ParentNodeId { get; } + + /// -1 for a row entry; 0 for a first-level node. + public int Depth { get; } + + /// True when the entry can show children (false after a zero-children fetch). + public bool HasChildren { get; } + + /// True when the entry's children are shown. + public bool IsExpanded { get; } + + /// True for a top-level row entry. + public bool IsRowEntry => NodeId == null; + } + + /// Pure index operations over a visible order (#1007). No I/O. + public static class BreadcrumbVisibleOrder + { + /// Index of the entry for the row and node, or -1. + /// The visible order. Required. + /// The owning row index. + /// The node identity, or null for the row entry. + /// is null. + public static int IndexOf( + IReadOnlyList order, + int rowIndex, + string? nodeId + ) + { + Require(order); + for (int index = 0; index < order.Count; index++) + { + if ( + order[index].RowIndex == rowIndex + && string.Equals(order[index].NodeId, nodeId, StringComparison.Ordinal) + ) + { + return index; + } + } + return -1; + } + + /// + /// Index after moving by , clamped to the first or last entry with + /// no wrap; -1 when the result equals or the index is invalid. + /// + /// is null. + public static int Step(IReadOnlyList order, int index, int delta) + { + Require(order); + if (index < 0 || index >= order.Count) + { + return -1; + } + long target = Math.Min(Math.Max((long)index + delta, 0L), order.Count - 1L); + return target == index ? -1 : (int)target; + } + + /// Index of the first entry, or -1 for an empty order. + /// is null. + public static int First(IReadOnlyList order) => + Require(order).Count == 0 ? -1 : 0; + + /// Index of the last entry, or -1 for an empty order. + /// is null. + public static int Last(IReadOnlyList order) => + Require(order).Count - 1; + + /// + /// Index of the parent entry: the parent node for a nested node, the row entry for a + /// first-level node, and -1 for a row entry or an invalid index. + /// + /// is null. + public static int ParentOf(IReadOnlyList order, int index) + { + Require(order); + if (index < 0 || index >= order.Count || order[index].IsRowEntry) + { + return -1; + } + return IndexOf(order, order[index].RowIndex, order[index].ParentNodeId); + } + + /// + /// Appends shown descendant nodes in pre-order; a node's children are appended only while + /// it is expanded. Shared by the EFC row and the QFC model. + /// + internal static void AppendNodes( + int rowIndex, + IReadOnlyList nodes, + string? parentNodeId, + int depth, + List target + ) + { + foreach (BreadcrumbDescendantNode node in nodes) + { + target.Add( + new BreadcrumbVisiblePosition( + rowIndex, + node.Identity, + parentNodeId, + depth, + NodeHasChildren(node), + node.Expanded + ) + ); + if (node.Expanded) + { + AppendNodes(rowIndex, node.Children, node.Identity, depth + 1, target); + } + } + } + + /// + /// True when a node can show children: its segment reports children and a completed fetch + /// did not return zero (#1007 AC14). + /// + internal static bool NodeHasChildren(BreadcrumbDescendantNode node) => + node.Segment.HasChildren && !(node.Loaded && node.Children.Count == 0); + + private static IReadOnlyList Require( + IReadOnlyList order + ) => order ?? throw new ArgumentNullException(nameof(order)); + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs index 9c7d8fc86..0b3df1df5 100644 --- a/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs +++ b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs @@ -42,6 +42,8 @@ CancellationToken cancellationToken if (node.Expanded) { node.Collapse(); + // #1007 AC7: a pending node hidden by this collapse moves to its nearest visible ancestor. + _selectionSession.ReconcilePendingNode(); return new[] { RenderJsonCore() }; } node.Expanded = true; @@ -59,7 +61,12 @@ CancellationToken cancellationToken lock (_sync) { row = RowAt(rowIndex); - if (row.CollapsedAfterIndex != null) + // #1007 R3 (D-Q23): a row shortened at its active segment lists that segment's + // children and stays shortened; only a collapse anchored elsewhere is restored. + if ( + row.CollapsedAfterIndex != null + && row.CollapsedAfterIndex != row.ActiveSegmentIndex + ) { row.ReExpand(); return new[] { RenderJsonCore() }; @@ -67,9 +74,10 @@ CancellationToken cancellationToken if (row.LeafExpanded) { row.TryCollapseLeaf(); + _selectionSession.ReconcilePendingNode(); return new[] { RenderJsonCore() }; } - if (!row.TryExpandLeaf()) + if (!row.TryExpandActiveSegment()) { return ErrorResponse($"Row {rowIndex} has no expand affordance."); } @@ -83,6 +91,11 @@ private async Task> ArrowAsync( CancellationToken cancellationToken ) { + if (Read(() => _selectionSession.IsOpen)) + { + // #1007: with the list open, Left and Right act on the pending entry. + return await PendingArrowAsync(direction, cancellationToken).ConfigureAwait(false); + } BreadcrumbStateRow row; int rowIndex; lock (_sync) diff --git a/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Navigation.cs b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Navigation.cs new file mode 100644 index 000000000..b55d0c3b5 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Navigation.cs @@ -0,0 +1,232 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// How the host acts on a Left or Right arrow while the selector list is open (#1007). + public enum BreadcrumbPendingArrowAction + { + /// The router handles the arrow on the pending entry. + Route, + + /// Left on a non-expanded pending entry: commit the pending entry and close. + CommitAndClose, + + /// Right on a pending entry without children: cancel, then pop out. + CancelAndPopOut, + } + + /// + /// Issue #1007 selector navigation over the visible order. While the list is open, Left and + /// Right act on the pending entry (spec decision table), never on the committed row. + /// + public sealed partial class FolderBreadcrumbBridgeRouter + { + private static readonly log4net.ILog log = log4net.LogManager.GetLogger( + System.Reflection.MethodBase.GetCurrentMethod().DeclaringType + ); + + /// Moves the open-list pending highlight to the first or last visible entry. + public BreadcrumbSelectionTransition MoveSelectorToBoundary(bool first) => + Mutate(() => PendingOnly(_selectionSession.MoveToBoundary(first))); + + /// Moves the open-list pending highlight by a page of visible entries. + public BreadcrumbSelectionTransition MoveSelectorPage(bool previous, int pageSize) => + Mutate(() => PendingOnly(_selectionSession.MovePage(previous, pageSize))); + + /// + /// Moves to the parent entry (Ctrl+Left): pending only while open, committed immediately + /// while closed (D-Q19). The render reflects a shortened row. + /// + public BreadcrumbSelectionTransition MoveSelectorToParent() => + Mutate(() => + { + bool wasOpen = _selectionSession.IsOpen; + if (!_selectionSession.MoveToParent()) + { + return BreadcrumbSelectionEffects.None; + } + return BreadcrumbSelectionEffects.Handled + | BreadcrumbSelectionEffects.RenderRequired + | ( + wasOpen + ? BreadcrumbSelectionEffects.None + : BreadcrumbSelectionEffects.SelectionChanged + ); + }); + + /// Commits the first or last top-level row of a closed selector (D-Q16). + public BreadcrumbSelectionTransition CommitSelectorBoundaryRow(bool first) => + Mutate(() => + _selectionSession.CommitBoundaryRow(first) + ? BreadcrumbSelectionEffects.Handled + | BreadcrumbSelectionEffects.SelectionChanged + | BreadcrumbSelectionEffects.RenderRequired + : BreadcrumbSelectionEffects.None + ); + + /// + /// Activates, commits and shortens a row at a segment, closing an open selector (AC9, S5). + /// + /// The stable identity of a selectable suggestion row. + /// A non-leaf segment index of that row. + /// An unhandled transition for an unknown row or an out-of-range index. + public BreadcrumbSelectionTransition DoubleClickSelectorSegment( + string rowIdentity, + int segmentIndex + ) => Mutate(() => _selectionSession.DoubleClickSegment(rowIdentity, segmentIndex)); + + /// Moves only the open-list pending highlight to a row (D-Q27); closed is a no-op. + public BreadcrumbSelectionTransition HighlightSelector(string identity) => + Mutate(() => _selectionSession.HighlightIdentity(identity)); + + /// + /// The bridge-channel segmentDoubleClick is a logged no-op (S5): the QFC double-click + /// travels on the selector channel so it can commit and close, and a stale page must not + /// shorten a row without committing it. + /// + private static IReadOnlyList IgnoreBridgeSegmentDoubleClick( + SegmentDoubleClickMessage message + ) + { + log.Debug( + $"Ignored bridge segmentDoubleClick for row {message.RowIndex}, segment {message.SegmentIndex}; " + + "the selector channel carries segment double-clicks (#1007 S5)." + ); + return Array.Empty(); + } + + /// + /// Classifies an arrow against the pending entry of an open selector (AC4, AC6). A closed + /// selector always routes. Left on a non-expanded entry commits and closes (D-Q15); Right + /// on an entry without children cancels and pops out (D-Q21); every other arrow routes. + /// + public BreadcrumbPendingArrowAction ClassifyPendingArrow( + BreadcrumbArrowDirection direction + ) => + Read(() => + { + BreadcrumbVisiblePosition? entry = PendingEntryCore(out _); + if (entry == null) + { + return BreadcrumbPendingArrowAction.Route; + } + if (direction == BreadcrumbArrowDirection.Left) + { + return entry.IsExpanded + ? BreadcrumbPendingArrowAction.Route + : BreadcrumbPendingArrowAction.CommitAndClose; + } + return entry.HasChildren + ? BreadcrumbPendingArrowAction.Route + : BreadcrumbPendingArrowAction.CancelAndPopOut; + }); + + /// + /// Open-list Right and Left on the pending entry. Right expands an entry with children or + /// pends its first child once expanded (AC5); Left collapses an expanded entry and keeps + /// the pending highlight (AC7). Anything else is reported as an unhandled arrow. + /// + private async Task> PendingArrowAsync( + BreadcrumbArrowDirection direction, + CancellationToken cancellationToken + ) + { + BreadcrumbStateRow row; + BreadcrumbDescendantNode? node; + int rowIndex; + lock (_sync) + { + BreadcrumbVisiblePosition? entry = PendingEntryCore(out var order); + if (entry == null) + { + return Unhandled(direction); + } + rowIndex = entry.RowIndex; + row = _model.Rows[rowIndex]; + node = entry.NodeId == null ? null : row.DescendantRoot?.Find(entry.NodeId); + if (direction == BreadcrumbArrowDirection.Left) + { + return CollapsePendingCore(row, node) + ? new[] { RenderJsonCore() } + : Unhandled(direction); + } + if (entry.IsExpanded) + { + int next = order.IndexOf(entry) + 1; + bool hasChild = + next < order.Count + && order[next].RowIndex == rowIndex + && order[next].Depth == entry.Depth + 1; + if (hasChild) + { + _selectionSession.Pend(order[next]); + return Array.Empty(); + } + // An expanded entry whose fetch is still in flight does nothing (decision table). + var expanded = node ?? row.DescendantRoot; + return expanded != null && !expanded.Loaded + ? Array.Empty() + : Unhandled(direction); + } + if (node == null) + { + if (!BreadcrumbStateModel.Expand(row)) + { + return Unhandled(direction); + } + if (!row.LeafExpanded || row.Subfolders.Count != 0) + { + return new[] { RenderJsonCore() }; + } + } + else if (!BreadcrumbVisibleOrder.NodeHasChildren(node)) + { + return Unhandled(direction); + } + } + if (node != null) + { + return await ToggleDescendantAsync(row.Identity, node.Identity, cancellationToken) + .ConfigureAwait(false); + } + return await FetchAndAttachSubfoldersAsync(rowIndex, row, cancellationToken) + .ConfigureAwait(false); + } + + /// Collapses the pending entry when expanded; a hidden pending node moves up (AC7). + private bool CollapsePendingCore(BreadcrumbStateRow row, BreadcrumbDescendantNode? node) + { + bool collapsed = node == null ? row.TryCollapseLeaf() : node.Expanded; + if (node != null && collapsed) + { + node.Collapse(); + } + _selectionSession.ReconcilePendingNode(); + return collapsed; + } + + /// The pending entry of an open selector, or null when closed or unresolved. + private BreadcrumbVisiblePosition? PendingEntryCore( + out List order + ) + { + order = _model.VisiblePositions(); + if (!_selectionSession.IsOpen) + { + return null; + } + int index = _selectionSession.PendingPosition(order); + return index < 0 ? null : order[index]; + } + + private static BreadcrumbSelectionEffects PendingOnly(bool moved) => + moved ? BreadcrumbSelectionEffects.Handled : BreadcrumbSelectionEffects.None; + + private static IReadOnlyList Unhandled(BreadcrumbArrowDirection direction) => + new[] { BreadcrumbBridgeSerializer.Serialize(new UnhandledArrowMessage(direction)) }; + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs index b536c0245..78c00c3bf 100644 --- a/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs +++ b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs @@ -307,8 +307,7 @@ CancellationToken cancellationToken switch (message) { case SegmentDoubleClickMessage m: - RowAt(m.RowIndex).CollapseAfter(m.SegmentIndex); - return new[] { RenderJson() }; + return IgnoreBridgeSegmentDoubleClick(m); case AffordanceToggleMessage m: return await ToggleAsync(m.RowIndex, cancellationToken) .ConfigureAwait(false); diff --git a/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs b/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs index bfb2e351d..a3d4cba26 100644 --- a/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs +++ b/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs @@ -108,7 +108,7 @@ CancellationToken cancellationToken { var snapshot = await AcquireSnapshotAsync(cancellationToken).ConfigureAwait(false); var chain = FolderTreeSnapshotQueries.GetAncestorChain(snapshot, leafKey); - var mapped = MapNodes(chain); + var mapped = MapNodes(snapshot, chain); // The trim runs AFTER the snapshot walk and BEFORE the caller sees the chain, so row // order, banner placement and the trash pseudo-row are all untouched (#799 AC1, AC2). @@ -178,7 +178,7 @@ CancellationToken cancellationToken { var snapshot = await AcquireSnapshotAsync(cancellationToken).ConfigureAwait(false); var children = snapshot.GetChildren(segmentKey); - return MapNodes(children); + return MapNodes(snapshot, children); } /// @@ -283,19 +283,26 @@ private Task AcquireSnapshotAsync(CancellationToken cancella } private static IReadOnlyList MapNodes( + FolderTreeSnapshot snapshot, IReadOnlyList nodes ) { - return nodes.Select(MapNode).ToArray(); + return nodes.Select(node => MapNode(snapshot, node)).ToArray(); } - private static FolderBreadcrumbSegment MapNode(FolderTreeSnapshotNode node) + // #1007 AC14: HasChildren counts only the child keys that resolve in the snapshot, which + // is the set GetImmediateSubfoldersAsync returns. The raw ChildKeys count can include a + // reader-reported key whose folder is absent, which drew an expander with nothing below it. + private static FolderBreadcrumbSegment MapNode( + FolderTreeSnapshot snapshot, + FolderTreeSnapshotNode node + ) { return new FolderBreadcrumbSegment( node.Key, node.DisplayName, node.FolderPath, - node.ChildKeys.Count > 0 + snapshot.GetChildren(node.Key).Count > 0 ); } } diff --git a/UtilitiesCS/UtilitiesCS.csproj b/UtilitiesCS/UtilitiesCS.csproj index f6134833c..20b731d9e 100644 --- a/UtilitiesCS/UtilitiesCS.csproj +++ b/UtilitiesCS/UtilitiesCS.csproj @@ -651,10 +651,13 @@ + + + diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/code-review.2026-10-25T23-25.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/code-review.2026-10-25T23-25.md new file mode 100644 index 000000000..02dc2cfc0 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/code-review.2026-10-25T23-25.md @@ -0,0 +1,86 @@ +# Code Review: EFC and QFC arrow keys skip expanded subfolders (Issue #1007) + +- Branch: `bug/efc-arrow-keys-skip-subfolders-1007` +- Head: `8bdd970ee`; merge base `3b9a1ebdd` (`main`, current `origin/main`) +- Review timestamp: 2026-10-25T23-25 +- Reviewer: feature-review agent +- Scope: the full branch diff `3b9a1ebdd..HEAD` (116 files: 25 production C# files, 1 page script, 25 test C# files, 3 node test files, 4 project files, feature documents and evidence, 1 promotion record) + +## Executive Summary + +The change is well structured for its size. One pure helper (`BreadcrumbVisibleOrder`) gives both viewers the same pre-order traversal, and new behaviour lives in new partials, so no file breaches the 500-line ceiling. The EFC router keeps its position as the existing pair of selected row and selected descendant, and every keyboard commit goes through `SelectHierarchyPath` or `SelectRowEntry`, so the #614 archive-root contract is preserved. The QFC session adds a pending descendant node whose setter coupling (`PendingIdentity` clears `PendingNode`) prevents a stale node after a row-level move. The re-entrant Right defect is closed with an explicit in-flight flag and an idempotent `ExpandLeaf`, and AC14 is fixed where the flag is derived (`MapNode` now counts `snapshot.GetChildren`). The removed #440 behaviour is removed consistently in both viewers, and each rewritten test records the reason. + +One blocking finding: the QFC page renders a pending, non-committed descendant as `class="sub active"`, but the stylesheet has no `.sub.active` rule, and the row's own `active` class is suppressed while a pending node owns the highlight. Arrowing into a subfolder in the open QFC list therefore leaves no visible highlight. The remaining findings are minor or informational. + +Verdict: **REMEDIATION_REQUIRED (1 blocking, 2 minor, 5 informational)**. + +## Findings Table + +| Severity | File | Location | Finding | Recommendation | Rationale | Evidence | +|---|---|---|---|---|---|---| +| Blocking | `QuickFiler/Resources/FolderBreadcrumb.html`; `QuickFiler/Resources/FolderBreadcrumb.js` | stylesheet lines 109 to 112 and 158; `makeSubfolders` line 169; `makeRow` lines 122 to 127; `render` lines 243 to 246 | B-1: a pending descendant that is not the committed subfolder gets `class="sub active"`, which no CSS rule styles (only `.row.selected`, `.row.active` and `.sub.selected` exist). `render` passes `pendingNodeOwnsActive` into `makeRow`, which then omits the row's `active` class. The open QFC list shows no highlight after Down or Right moves onto a subfolder, which is the second reproduction in `spec.md`. | Add a `.sub.active` rule (for example extend `.sub.selected` to `.sub.selected, .sub.active`, or a distinct pending style with the same contrast in both themes) and a C# string-contract assertion over the QFC document HTML that the rule exists; optionally a node assertion that the active descendant's computed class is styled. | AC4 requires that arrows move the pending highlight; a highlight with no styling is not visible to the user. The #1002 review raised the same defect class (selection state without a matching CSS rule) as blocking. | Grep of `FolderBreadcrumb.html` for `.sub.active`, `.active`, `aria-selected`: only `.row.active`; `issue-1007-keys.test.mjs` "pending node carries sub active" and `FolderBreadcrumbKeysIssue1007Tests` assert the class string only. | +| Minor | `QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs`; `UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs`; `QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs`; `UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs`; `UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs` | `HandleLeafToggleAsync` lines 23 to 31; `ToggleAsync` non-anchored branch; closed `ArrowAsync`; `PublishRouterOutputs` and `RaiseSyntheticArrowKey` unhandled arms; inbound `UnhandledArrowMessage`; `Expand` non-anchored branch | N-1: 27 unchanged instrumented lines lost their only test execution, mainly the non-anchored `ReExpand` paths (reachable when a shortened segment has no provider key) and the legacy `unhandledArrow` route. Every file stays above 80% line and 75% branch and no changed line regressed, but these paths are now untested. | Add direct tests: an EFC `leafExpandToggle` on a row collapsed away from its active segment; a QFC `affordanceToggle` on a non-anchored collapsed row; a coordinator test that a router `unhandledArrow` output raises `UnhandledArrow`. Delete any branch confirmed unreachable instead of testing it. | Coverage is a supporting gate; defensive paths that keep behaviour should keep a test. | Reviewer mapping of baseline hits through `git diff -U0` hunks (policy audit section 5). | +| Minor | `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs`; `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs` | `HandleArrowKey_LeftOnMultiSegmentRow_ActivatesParentSegment`, `HandleArrowKey_RepeatedLeft_WalksToRootThenFallsThroughToExistingBehavior`, `HandleArrowKey_RightAfterExpansion_DescendsByChildActivation`, `RightArrow_OnCollapsedRow_ReExpandsBeforeLeafExpansion`, `LeftArrow_RepeatedOnThreeSegmentChain_WalksToRootThenReportsUnhandled`, `LeftArrow_WalkFromAnOpenLeafExpansion_ClearsTheExpansionAndStillReachesTheRoot` | N-2: the rewritten tests assert the #1007 behaviour but keep the #440 names, which now state the opposite (for example a test named `..._ActivatesParentSegment` asserts that Left does not activate the parent). | Rename each test to its asserted behaviour (for example `HandleArrowKey_LeftOnNonExpandedMultiSegmentRow_IsNoOp`) and keep the #1007 summary; record the rename in the disposition evidence. | UT3 requires names that communicate intent; a contradicting name misleads a reader triaging a failure. | Diff of both files. | +| Informational | `UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs`; `UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs` | `AppendSuggestionRow` (`!(anchoredOpen && row.HasNoLoadedChildren)`); `ProjectRow` (`zeroLoaded` requires `LeafExpanded`) | N-5: after a fetch that returned zero children, a Left collapse re-shows `+` on an EFC row shortened at its active segment and on a QFC row, because both defences apply only while expanded (the EFC leaf affordance for a non-shortened row is suppressed in both states). | Consider suppressing on `HasNoLoadedChildren` regardless of expansion in the EFC shortened-terminal branch, and on a loaded-empty `DescendantRoot` in the QFC projection; or accept, because the `MapNode` source fix makes the state reachable only if the snapshot changes between the chain and the child fetch. | AC14 defence in depth; the primary fix holds. | Source read. | +| Informational | `UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Navigation.cs` | whole file | N-3: branch coverage 78.33% (47 of 60); line 93.44%. Uncovered: `MoveSelectorToParent` no-op return, `PendingArrowAsync` null-entry and refused-expansion returns. | Add tests for the null-entry and refused-expansion returns if the remediation touches this file. | CLAUDE.md asks new modules to target 90%; the line figure meets it. | Reviewer Cobertura recomputation. | +| Informational | `UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs` | `PendingIdentity` property, `CommitPending` | N-4: the file grew from 446 to 492 lines although the spec allowed call-throughs only; the added logic (the `PendingIdentity` setter coupling and the three-way commit branch) is small and coherent. | Accept; keep further session logic in the Tree partial. | 500-line ceiling still met. | `awk` line count. | +| Informational | `UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.Tree.cs` | `MoveToParent` open-list row branch | O-4: an open-list Ctrl+Left on a row entry calls `ActivateSegment` and `CollapseAfter` on the model row before Enter. Escape restores the shape (D-Q24), but if the session ends through another commit (for example Down to a different row, then Enter), the shortened shape of the first row persists, and selecting it later files to the displayed parent folder. | Accept; the displayed and filed folders agree. Document in the PR body as part of the AC8 behaviour. | Consistent with S9 (`SelectedRowValue` files to the active segment). | Source read. | +| Informational | `QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs` | `ExpandLeafAsync` | O-5: the new in-flight flag closes the re-entrant Right defect; the pre-existing race in which a segment activation during an in-flight fetch receives the previous segment's children is unchanged (the row-instance guard covers rebinds only). | Out of scope; consider a generation check keyed on `ActiveSegmentIndex` in a later item. | Pre-existing and not reachable by keyboard alone within one fetch. | Source read. | + +## Design and Correctness Review + +### Shared visible order + +- `BreadcrumbVisibleOrder` is pure and fully covered (68 of 68 lines, 30 of 30 branches). `Step` clamps in `long` arithmetic, so a large page size cannot overflow, and returns -1 for "no move", which callers use for the no-wrap rule (D-Q5) and for the EFC Up-to-search-box hand-off. +- `NodeHasChildren` applies the AC14 zero-children rule once for both viewers; `AppendVisiblePositions` (EFC) and `VisiblePositions` (QFC) apply the same rule at row level. + +### EFC router + +- `PositionOf` falls back to the row entry when the selected descendant is not visible, so a key press after a collapse still has a defined position. +- Right and Left follow the spec decision table exactly; an expanded node whose fetch is in flight has no child in the order, so Right does nothing (verified by `ArrowRight_OnExpandedNodeWithFetchInFlight_DoesNothing`). +- `CollapseRowLeaf` and `CollapseNode` implement S1: the re-commit happens only when the collapse hides the selection (`IsSelectedDescendantWithin` excludes the node itself), so collapsing the selected node keeps the highlight on it (D-Q11). +- `ShortenAt` is atomic (activate, commit, shorten, one render) and keeps the pre-#1007 collapse-only behaviour with a debug log when the segment has no key. The second-click rejection is downgraded to debug only when the segment is already active (S10). +- `ProcessInboundAsync` routes an `arrowKey` with an empty row id to `HandleNoSelectionKey` before the unknown-row rejection (D-Q26). +- No `ConfigureAwait(false)` and no lock were added to the EFC router files (I6). + +### QFC session, router and coordinator + +- The `PendingIdentity` setter clears `PendingNode`, and `Pend` sets the node after the identity, so every row-level move drops a stale node. +- `BeginOpenTree` places the pending node on the committed subfolder only when it is visible (D-Q29); `ReconcilePendingNode` walks the path from the top and stops at the first collapsed ancestor, which is the deepest visible ancestor (AC7). +- `CommitPending` has three branches: commit the pending node; replace a visible committed descendant with the row entry; or select a different row. A hidden committed descendant on the same row is kept, which matches "Enter on the row entry re-confirms the hidden choice". +- `ClassifyPendingArrow` and `PendingArrowAsync` read the same visible order under `_sync`; the coordinator applies `CommitAndClose` and `CancelAndPopOut` through `_dispatcher.Dispatch`, and the pop-out raises `UnhandledArrow` only after `CancelSelector()` completes (D-Q21). +- The bridge-channel `segmentDoubleClick` is now a logged no-op (S5); the selector channel carries `selectorSegmentDoubleClick` with the row identity. +- `ParseKey` derives every wire value from the lower-cased enum name, so `Serialize` and `ParseKey` cannot drift. + +### AC14 + +- `MapNode` derives `HasChildren` from `snapshot.GetChildren(node.Key).Count > 0` for both `GetAncestorChainAsync` and `GetImmediateSubfoldersAsync`; it is the only `FolderBreadcrumbSegment` construction site in `UtilitiesCS` (reviewer Grep). The cost is O(child keys) per mapped node over an already acquired snapshot. +- `OutlookFolderHierarchyProviderIssue1007Tests` builds a snapshot with a reader-reported child key that has no node and asserts `HasChildren == false` through both entry points, with a resolvable-child guard. + +### Page scripts + +- `BridgeJs`: Ctrl+ArrowLeft maps to `ParentLeft`, Ctrl+ArrowRight stays `Right`, page keys carry `fullyVisibleCount()` (minimum 1), and the render handler scrolls the selected `.child` into view before falling back to the row. The EFC page has no text input, so intercepting Home and End is safe. +- `FolderBreadcrumb.js`: the `onArrow` pre-filter is removed (S6); Ctrl+ArrowLeft posts `parent`; page keys carry `visiblePageSize()`; the non-leaf segment click stops propagation only in the open, expanded list; the segment double-click posts `selectorSegmentDoubleClick`. The handler is attached to the list and the drop-down button only. The visual gap is B-1. + +### Removed #440 behaviour and test dispositions + +- `BreadcrumbStateModel.LeftArrow` now only collapses and clears the subfolder selection; `RightArrow` delegates to `Expand`, which restores only a collapse anchored away from the active segment. `TryRightTreeTransition` and the EFC `TryRightTreeTransitionAsync` are deleted. +- `BreadcrumbRow.LeftArrow` no longer hides trailing segments; `ToggleLeafExpanded` and `CanExpandActiveSegment` allow expansion on an anchored collapse. +- The rewritten tests in `BreadcrumbBridgeRouterTests.cs`, `BreadcrumbBridgeRouterTests.Selection.cs`, `BreadcrumbRowStateTests.cs`, `BreadcrumbStateModelTests.cs`, `BreadcrumbStateModelSequenceTests.cs`, `FolderBreadcrumbBridgeRouterTests.cs`, `FolderBreadcrumbBridgeRouterInFlightTests.cs`, `BreadcrumbBridgeCoordinatorTests.cs`, `BreadcrumbSelectorCoordinatorTests.cs` and `FolderBreadcrumbAssetContractTests.cs` match the spec disposition table; each summary cites #1007 and the governing AC. `LeftArrow_WithSubfolderSelected_ResetsSubfolderSelectionAndCollapses` keeps its assertions as the table requires. Names are N-2. + +## Best-Practice Checklist + +| Area | Verdict | Note | +|---|---|---| +| Naming and documentation | PASS with note | XML documentation on every new public member; N-2 test names. | +| Error handling | PASS | Logged no-ops for invalid input; constructor guards on new messages; no broad catch added. | +| Async correctness | PASS | `TaskCompletionSource` in tests; `try`/`finally` releases the in-flight flag in `ExpandLeafAsync`. | +| Thread safety | PASS | EFC on the UI context (I6); QFC state under `_sync`; coordinator marshals through the dispatcher. | +| File size | PASS | Largest changed code file 500 lines. | +| Language level | PASS | net48: plain sealed class, no `record` or `init`. | +| UI state has visible styling | FAIL | B-1. | +| Test coverage of changed behaviour | PASS with note | N-1, N-3. | + +## Recommendation + +Address B-1 in a short remediation cycle: one stylesheet rule in `QuickFiler/Resources/FolderBreadcrumb.html`, one C# string-contract assertion (in `FolderBreadcrumbKeysIssue1007Tests.cs`, 158 lines, or `FolderBreadcrumbAssetContractTests.cs`, 429 lines), a full C# toolchain pass in CLAUDE.md order and a node run. In the same cycle, consider the N-1 tests and the N-2 renames, which touch only test files. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/code-review.2026-10-26T00-14.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/code-review.2026-10-26T00-14.md new file mode 100644 index 000000000..210d588ad --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/code-review.2026-10-26T00-14.md @@ -0,0 +1,76 @@ +# Code Review: EFC and QFC arrow keys skip expanded subfolders (Issue #1007) + +- Branch: `bug/efc-arrow-keys-skip-subfolders-1007` +- Head: `e804f5d6b`; merge base `3b9a1ebdd` (`main`, current `origin/main`) +- Review timestamp: 2026-10-26T00-14 (re-audit after remediation cycle 1) +- Previous review: `code-review.2026-10-25T23-25.md` +- Reviewer: feature-review agent +- Scope: the full branch diff `3b9a1ebdd..HEAD` (149 files: 25 production C# files, 1 page script, 1 page HTML resource, 26 test C# files, 3 node test files, 4 project files, feature documents and evidence, 1 promotion record). The remediation commit `e804f5d6b` was read hunk by hunk; the rest of the diff is unchanged since the first review and was re-read for the areas the cycle touches. + +## Executive Summary + +The cycle resolves the one blocking finding and both minor findings of the first review, with the smallest change that fits each. + +- B-1: the selector list of the existing committed-subfolder rule becomes `.sub.selected, .sub.active`. The pending descendant now has the same background and foreground as a committed subfolder, and because both values are CSS variables redefined in the explicit dark theme and the `prefers-color-scheme: dark` block, the contrast holds in both themes. `FolderBreadcrumb.js` is unchanged. The new C# contract searches only the ``; `RuleDeclarations` matches `selectors { body }` without nested braces (the `@media` wrapper is skipped because its selector text cannot contain a brace) and compares each comma-separated selector after `Trim()` for equality. Fail-before: 1 of 9 failed with `Expected declarations not to be because the QFC stylesheet must style the pending descendant (.sub.active).`; pass-after: 24 of 24 with `FolderBreadcrumbAssetContractTests`. +- The EFC page needed no change: its selected descendant is styled by `.child[aria-selected="true"]` (`BreadcrumbDocumentAssets.cs` lines 36 and 43). + +### N-1 (resolved) + +| Test | Path reached | Assertions | +|---|---|---| +| `LeafExpandToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChain` | EFC `HandleLeafToggleAsync` non-anchored restore, reached from inbound messages (double-click at segment 1, then `segmentActivate` at 0) | Beta segment rendered again; no fetch for Acme | +| `AffordanceToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChainWithoutQuery` | QFC `ToggleAsync` non-anchored restore through the public `CollapseAfter` contract | one render output; `CollapsedAfterIndex` null; `LeafExpanded` false; no subfolder query for Alpha or Projects | +| `RightArrow_OnRowCollapsedAwayFromActiveSegment_RestoresChain` | `BreadcrumbStateModel.Expand` non-anchored restore | handled; chain restored; leaf not expanded; active index 2 | +| `OpenUnhandledArrowLeft_OnExpandedPendingRow_RaisesUnhandledArrowFromRouterOutput` | coordinator `PublishRouterOutputs` and `RaiseSyntheticArrowKey` `UnhandledArrowMessage` arms | one `unhandled:Left` event; one synthetic key; list still open; committed folder unchanged | + +Every N-1 body line moved from 0 hits to at least 1 (`coverage-comparison.2026-10-26T00-04.md`). The plan's reachability argument (D3) is sound: the EFC state is reachable from inbound messages, and the QFC states are reachable through a public method that accepts any non-leaf index, so keeping the branches and testing them is preferable to deleting them inside a cycle that must not change the state machine. + +### N-2 (resolved) + +The six renames in `BreadcrumbBridgeRouterTests.cs` and `BreadcrumbStateModelSequenceTests.cs` change one signature line each; the diff hunks show no body change. Each new name matches the summary and assertions (for example `LeftArrow_FromOpenLeafExpansion_CollapsesThenIsUnhandled`). + +## Design and Correctness Review + +The first review's conclusions on the shared visible order, the EFC router, the QFC session, router and coordinator, the AC14 source fix, the page scripts and the removed #440 behaviour are unchanged, because the cycle edited no C# production file and no page script. The cycle's test additions do not depend on internal state beyond what sibling tests in the same partial classes already use (`Harness`, `RouterAsync`, `ExpandAlphaAsync`, `ThreeSegmentAsync`, `ModelWithSuggestion`). The new partial files declare no member that the main partials declare and carry no `[TestClass]` of their own; MSTest discovers their methods through the main declaration. + +## Best-Practice Checklist + +| Area | Verdict | Note | +|---|---|---| +| Naming and documentation | PASS | N-2 resolved; CR-I1 comment wording is informational. | +| Error handling | PASS | Unchanged. | +| Async correctness | PASS | New async tests await every call; no blocking waits. | +| Thread safety | PASS | Unchanged. | +| File size | PASS | Largest changed code file 500 lines (unchanged); cycle-edited files at most 477. | +| Language level | PASS | QuickFiler.Test partial uses C# 7.3 syntax; UtilitiesCS.Test partial carries `#nullable enable` and uses tuple deconstruction already used by its main partial. | +| UI state has visible styling | PASS | B-1 resolved. | +| Test coverage of changed behaviour | PASS | N-1 resolved; N-3 informational. | + +## Recommendation + +No remediation is required. Before the pull request, regenerate the PR-context artifacts (O-1), let CI run on the PR head (O-2), and complete the manual Outlook verification recorded by the orchestrator (O-3). diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-coverage.2026-10-23T13-43.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-coverage.2026-10-23T13-43.md new file mode 100644 index 000000000..5d3174306 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-coverage.2026-10-23T13-43.md @@ -0,0 +1,36 @@ +# Baseline Coverage (Issue #1007) + +Timestamp: 2026-10-23T13-43 +Command: pwsh -NoProfile -File scripts/vscode/Invoke-MSTestWithCoverage.ps1 (unscoped run; launched from a helper under SCRATCH with Set-Location to the worktree) +EXIT_CODE: 0 +Output Summary: +- Test run (summary derived from the trx by the script): total 7599, executed 7599, passed 7599, failed 0, skipped 0. The shell-icon carve-out was not needed: the run completed in 39.3 seconds with no stall. +- BASELINE-FAILURES: none (empty list). +- One-line first-party coverage summary: `First-party coverage: lines 57539/66677 (86.30%), branches 14035/17419 (80.57%)`. +- Repository line coverage 86.30 percent; repository branch coverage 80.57 percent. +- The post-processed Cobertura document was copied to SCRATCH as `issue-1007-baseline-cobertura.xml` immediately after the run (not committed). + +## Per-file coverage of PROD-MODIFY C# files (post-processed Cobertura, lines de-duplicated per line number) + +| File | Line | Branch | +|---|---|---| +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs | 161/163 (98.77%) | 87/94 (92.55%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.Descendants.cs | 82/82 (100.00%) | 35/36 (97.22%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs | 175/179 (97.77%) | 54/58 (93.10%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs | not instrumented (constant-only file, no executable line in the Cobertura document) | not instrumented | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs | 120/120 (100.00%) | 64/64 (100.00%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs | 32/32 (100.00%) | 14/14 (100.00%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs | 128/130 (98.46%) | 39/42 (92.86%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.Row.cs | 186/199 (93.47%) | 84/96 (87.50%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs | 303/303 (100.00%) | 99/102 (97.06%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs | 68/68 (100.00%) | 25/26 (96.15%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectorMessages.cs | 203/203 (100.00%) | 60/62 (96.77%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs | 152/152 (100.00%) | 44/46 (95.65%) | +| UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs | 256/256 (100.00%) | 57/60 (95.00%) | +| UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs | 140/144 (97.22%) | 59/64 (92.19%) | +| UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs | 119/121 (98.35%) | 24/26 (92.31%) | +| QuickFiler/Controllers/BreadcrumbBridgeRouter.cs | 244/246 (99.19%) | 95/100 (95.00%) | +| QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs | 134/139 (96.40%) | 50/56 (89.29%) | +| QuickFiler/Controllers/BreadcrumbBridgeRouter.Selection.cs | 181/184 (98.37%) | 65/68 (95.59%) | +| QuickFiler/Controllers/BreadcrumbBridgeRouter.Descendants.cs | 63/63 (100.00%) | 11/12 (91.67%) | +| QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs | 251/251 (100.00%) | 79/89 (88.76%) | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-file-sizes.2026-10-23T13-42.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-file-sizes.2026-10-23T13-42.md new file mode 100644 index 000000000..d5efd56ab --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-file-sizes.2026-10-23T13-42.md @@ -0,0 +1,71 @@ +# Baseline File Sizes (Issue #1007) + +Timestamp: 2026-10-23T13-42 +Command: physical line count per file, `@(Get-Content -LiteralPath ).Count` (helper under SCRATCH; not Measure-Object -Line) +EXIT_CODE: 0 +Output Summary: 50 files counted; every count below is numeric. The largest are QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs (500) and QuickFiler/Viewers/BreadcrumbItemViewerLifecycleCoordinator.cs (497, EXCLUDED). + +## PROD-MODIFY + +| Lines | File | +|---|---| +| 370 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs | +| 200 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.Descendants.cs | +| 298 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs | +| 185 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs | +| 220 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs | +| 197 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs | +| 268 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs | +| 408 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.Row.cs (NO-GROWTH) | +| 446 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs (NO-GROWTH) | +| 144 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs | +| 371 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectorMessages.cs | +| 289 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs | +| 404 | UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs (NO-GROWTH) | +| 225 | UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs | +| 302 | UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs | +| 471 | QuickFiler/Controllers/BreadcrumbBridgeRouter.cs (NO-GROWTH) | +| 222 | QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs | +| 305 | QuickFiler/Controllers/BreadcrumbBridgeRouter.Selection.cs | +| 104 | QuickFiler/Controllers/BreadcrumbBridgeRouter.Descendants.cs | +| 451 | QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs (NO-GROWTH) | +| 350 | QuickFiler/Resources/FolderBreadcrumb.js | + +## Disposition Register test files (NO-GROWTH tests marked) + +| Lines | File | +|---|---| +| 462 | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs (NO-GROWTH) | +| 307 | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | +| 368 | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | +| 379 | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | +| 454 | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs (NO-GROWTH) | +| 292 | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | +| 243 | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | +| 495 | UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs (NO-GROWTH) | +| 383 | UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | +| 354 | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs | +| 488 | QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs (NO-GROWTH) | +| 500 | QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs (NO-GROWTH) | +| 415 | QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs | +| 239 | tests/quickfiler-breadcrumb/issue-997-common.test.mjs | +| 128 | tests/quickfiler-breadcrumb/issue-997-browser-harness.mjs | + +## EXCLUDED + +| Lines | File | +|---|---| +| 414 | QuickFiler/Controllers/KeyboardHandler.cs | +| 219 | QuickFiler/Controllers/EfcFormController.Breadcrumb.cs | +| 490 | QuickFiler/Viewers/BreadcrumbMessengerHub.cs | +| 497 | QuickFiler/Viewers/BreadcrumbItemViewerLifecycleCoordinator.cs | +| 395 | QuickFiler/Viewers/BreadcrumbDropDownOpenCoordinator.cs | +| 72 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbDescendantNode.cs | +| 376 | UtilitiesCS/OutlookObjects/Folder/BreadcrumbBridgeMessages.cs | +| 137 | UtilitiesCS/OutlookObjects/Folder/FolderTreeSnapshot.cs | +| 84 | UtilitiesCS/OutlookObjects/Folder/FolderTreeSnapshotBuilder.cs | +| 487 | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1002Tests.cs | +| 466 | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowDescendantsIssue1002Tests.cs | +| 462 | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.cs | +| 455 | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue439Tests.cs | +| 480 | UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-node-suites.2026-10-23T13-41.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-node-suites.2026-10-23T13-41.md new file mode 100644 index 000000000..aec1a78e5 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-node-suites.2026-10-23T13-41.md @@ -0,0 +1,9 @@ +# Baseline Node Suites (Issue #1007) + +Timestamp: 2026-10-23T13-41 +Command: node --test --test-concurrency=1 --test-reporter=spec --experimental-test-coverage --test-coverage-include=QuickFiler/Resources/FolderBreadcrumb.js tests/quickfiler-breadcrumb/issue-997-common.test.mjs tests/quickfiler-breadcrumb/issue-997-recursive.test.mjs tests/quickfiler-breadcrumb/issue-1002-nesting.test.mjs +EXIT_CODE: 0 +Output Summary: +- tests 80, pass 80, fail 0, cancelled 0, skipped 0. +- `FolderBreadcrumb.js` line coverage 100.00 percent (branch 100.00, functions 100.00), as reported by the node experimental coverage table. +- File order equals execution order (test concurrency 1): issue-997-common, issue-997-recursive, issue-1002-nesting. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-test-disposition.2026-10-23T13-46.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-test-disposition.2026-10-23T13-46.md new file mode 100644 index 000000000..873cbc429 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-test-disposition.2026-10-23T13-46.md @@ -0,0 +1,238 @@ +# Baseline Test Disposition (Issue #1007) + +Timestamp: 2026-10-23T13-46 +Command: test-tree search over UtilitiesCS.Test/, QuickFiler.Test/ and tests/quickfiler-breadcrumb/ for `LeftArrow(`, `RightArrow(`, `ReExpand(`, `CollapseAfter(`, `ToggleLeafExpanded(`, `segmentDoubleClick`, `unhandledArrow`, `arrowKey`, `HandleSelectorKey`, `MoveSelector(`, `MoveNext`, `MovePrevious`, `GetSelectedFolder`, `selectorKeys`, `onArrow`, each match attributed to its enclosing test method (helper under SCRATCH); plus a reflection search for GetField/GetMethod/GetProperty naming Move, CommitPending, Cancel, Open, ArrowAsync, ToggleAsync, MapNode or ExpandLeafAsync +EXIT_CODE: 0 +Output Summary: +- 208 matching test-method rows across 48 files; every row is classified (0 unclassified). 53 rows carry a Disposition Register entry; the rest are unaffected with the reason stated. +- Reflection search: 8 matches, all unrelated to the changed members (`OpenAsync` on the drop-down host, `OpensBelow`, `IsFolderDropDownOpen`, `ButtonCancel`, `_itemGroupsToMove`): unaffected. +- Every Disposition Register test appears below with its register disposition; register tests that the token search does not reach (their bodies call a local helper or a different API) are listed in the second section. +- No match was found that must change and is absent from the register, so the plan is not halted. + +## Register tests not reached by the token search + +| File | Test | Disposition | +|---|---|---| +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | Route_LeftArrow_NothingToCollapse_ReportsUnhandledLeft | REGISTER: rewrite in place (P10-T5; AC7) | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | ArrowAsync_QfcLeftOnMultiSegmentRow_RoutesParentSelectTransition | REGISTER: rewrite in place (P10-T5; AC7) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs | SelectorKeyMessage_RoundTripsOnlySupportedKeys | REGISTER: gains Parent, Home, End, PageUp, PageDown rows; unknown row `left` kept (P10-T4) | +| tests/quickfiler-breadcrumb/issue-997-common.test.mjs | clicking a selectable row uses its host identity | REGISTER: unchanged (its only segment is the leaf) | +| UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs | chain HasChildren assertions | REGISTER: unaffected, fixture child keys resolve | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1002Tests.cs | DescendantToggle_DoesNotChangeSelection | REGISTER: unaffected (toggle below an unselected position) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1002Tests.cs | DescendantActivate_ThenRowConfirmed_RaisesConfirmationOnce | REGISTER: unaffected | +| UtilitiesCS.Test/OutlookObjects/Folder (renderer suite) | Issue995BridgeJsPostsConfirmationForPlainEnterAndScrollsSelectedRowIntoView | REGISTER: unaffected (the selected `.rowwrap.selected` scroll fallback is kept) | +| QuickFiler.Test/Controllers (router suite) | LeafExpand_UsesBoundActiveSegmentKeyWithoutResolvingAgain | REGISTER: unaffected | +| QuickFiler.Test (fall-through suite) | Boundary_QfcUnhandledArrow_StillReachesBreadcrumbArrowFallThrough | REGISTER: unaffected | + +## Token-search matches (file, test, matched tokens, disposition) + +| File | Test | Tokens | Disposition | +|---|---|---|---| +| UtilitiesCS.Test/Extensions/IEnumerableExtensionsCoverageTests.cs | WithProgressReporting_IsDeferredUntilEnumeration | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| UtilitiesCS.Test/Extensions/IEnumerableExtensionsCoverageTests.cs | WithAction_IsDeferredAndRunsOncePerItem | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| UtilitiesCS.Test/Extensions/IEnumerableExtensionsCoverageTests.cs | Chunk_DoesNotEnumerateSourceBeforeResultIsEnumerated | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| UtilitiesCS.Test/Extensions/IEnumerableExtensionsCoverageTests.cs | SplitTestTrain_ValidatesNullEmptyAndInvalidPercentages | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| UtilitiesCS.Test/Extensions/TraceExtensions_Tests.cs | GetCallerByName_ReturnsMatchingMethodAndHandlesEmptySpecialAndMissingNames | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeMessagesTests.cs | Parse_MissingRequiredField_ThrowsFormatException | segmentDoubleClick | unaffected: BreadcrumbBridgeMessages.cs is EXCLUDED; bridge parsing of arrowKey directions and segmentDoubleClick fields is unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeMessagesTests.cs | Parse_UnknownArrowDirection_ThrowsFormatException | arrowKey | unaffected: BreadcrumbBridgeMessages.cs is EXCLUDED; bridge parsing of arrowKey directions and segmentDoubleClick fields is unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantSelectionIssue997Tests.cs | ActivateImmediateChild_BoundArchiveRoot_CommitsArchiveRelativeDestination | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantSelectionIssue997Tests.cs | CollapseAncestor_SelectedChild_PreservesCommittedDestination | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantSelectionIssue997Tests.cs | ReopenThenEscape_SelectedChild_RetainsCommittedDestination | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantSelectionIssue997Tests.cs | ActivateIntermediateAndLeaf_ThreeLevels_CommitsKnownRelativeTargets | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantSelectionIssue997Tests.cs | CollapseAndReopen_SelectedDescendant_RendersCommittedSelection | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantValidationIssue997Tests.cs | UnknownUnloadedAndCrossRowAddresses_CannotFetchOrCommit | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantValidationIssue997Tests.cs | BoundRoot_InvalidProviderTarget_CannotExpandOrCommit | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantValidationIssue997Tests.cs | DuplicateNamesAndStoreKeys_KeepRowOccurrencesDistinct | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantValidationIssue997Tests.cs | LegacySelectionChange_OutOfRootKnownChild_DoesNotMutateOrAcknowledge | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantValidationIssue997Tests.cs | LeavesCannotExpand_AndExpansionIsLazy | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDescendantValidationIssue997Tests.cs | ProviderFailure_PreservesSelectionAndPermitsRetry | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDuplicateIdentityTests.cs | SameOutputAcrossAllSources_PreservesOrderKindScoreAndSelectability | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDuplicateIdentityTests.cs | ClosedMoveNext_DuplicateOutputPathsCommitsSecondLogicalRow | GetSelectedFolder MoveNext | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDuplicateIdentityTests.cs | OpenMoveNextThenCommit_DuplicateOutputPathsCommitsSecondLogicalRow | GetSelectedFolder MoveNext | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDuplicateIdentityTests.cs | Activate_SecondDuplicateIdentityCommitsExactLogicalRow | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbDuplicateIdentityTests.cs | OpenCommit_CollapsedReadbackUsesSecondDuplicateSuggestionProbability | GetSelectedFolder MoveNext | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererIssue1002Tests.cs | RenderRowFragment_DescendantNames_AreHtmlEncoded | ToggleLeafExpanded( | unaffected: the AC14 defence applies only after a fetch that loaded zero children; these rows are expanded with children or without a fetch; the collapsed-terminal + is unchanged for a non-expanded shortened row | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererIssue1002Tests.cs | RenderRowFragment_KeylessChildren_RenderUnchanged | ToggleLeafExpanded( | unaffected: the AC14 defence applies only after a fetch that loaded zero children; these rows are expanded with children or without a fetch; the collapsed-terminal + is unchanged for a non-expanded shortened row | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererIssue1002Tests.cs | BaseCss_ChildName_CarriesEllipsisRule | ToggleLeafExpanded( | unaffected: the AC14 defence applies only after a fetch that loaded zero children; these rows are expanded with children or without a fetch; the collapsed-terminal + is unchanged for a non-expanded shortened row | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs | Issue439ActiveAncestorChildrenAndEmbeddedBridgeUseTypedStoppedActivation | ToggleLeafExpanded( | unaffected: the AC14 defence applies only after a fetch that loaded zero children; these rows are expanded with children or without a fetch; the collapsed-terminal + is unchanged for a non-expanded shortened row | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs | RenderRowFragment_CollapsedRow_StillEmitsTrailingPercent | CollapseAfter( | unaffected: the AC14 defence applies only after a fetch that loaded zero children; these rows are expanded with children or without a fetch; the collapsed-terminal + is unchanged for a non-expanded shortened row | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs | RenderRowFragment_LeafWithSubfolders_EmitsPlusWhenCollapsedMinusWhenExpanded | ToggleLeafExpanded( | unaffected: the AC14 defence applies only after a fetch that loaded zero children; these rows are expanded with children or without a fetch; the collapsed-terminal + is unchanged for a non-expanded shortened row | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs | RenderRowFragment_CollapsedState_RendersReExpandPlusAtTerminalSegment | CollapseAfter( | unaffected: the AC14 defence applies only after a fetch that loaded zero children; these rows are expanded with children or without a fetch; the collapsed-terminal + is unchanged for a non-expanded shortened row | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs | RenderRowFragment_ExpandedLeaf_RendersEncodedChildren | ToggleLeafExpanded( | unaffected: the AC14 defence applies only after a fetch that loaded zero children; these rows are expanded with children or without a fetch; the collapsed-terminal + is unchanged for a non-expanded shortened row | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbMessageCodecTests.cs | DeserializeInbound_SegmentDoubleClick_RoundTripsAllFields | segmentDoubleClick | unaffected: the codec adds an optional pageSize; existing arrowKey and segmentDoubleClick decoding is unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbMessageCodecTests.cs | DeserializeInbound_ArrowKey_RoundTripsKey | arrowKey | unaffected: the codec adds an optional pageSize; existing arrowKey and segmentDoubleClick decoding is unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbMessageCodecTests.cs | DeserializeInbound_WithWrongTypedSegmentIndex_ThrowsBreadcrumbMessageException | segmentDoubleClick | unaffected: the codec adds an optional pageSize; existing arrowKey and segmentDoubleClick decoding is unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbMessageCodecTests.cs | DeserializeInbound_SegmentDoubleClickWithoutIndex_ThrowsBreadcrumbMessageException | segmentDoubleClick | unaffected: the codec adds an optional pageSize; existing arrowKey and segmentDoubleClick decoding is unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbMessageCodecTests.cs | DeserializeInbound_ArrowKeyWithoutKey_ThrowsBreadcrumbMessageException | arrowKey | unaffected: the codec adds an optional pageSize; existing arrowKey and segmentDoubleClick decoding is unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRenderProjectionTests.cs | Project_CollapsedRow_RendersPlusThenTerminalSegmentOnly | CollapseAfter( | unaffected: collapsed row not expanded keeps the Plus then terminal segment projection | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowDescendantsIssue1002Tests.cs | CollapseAfter_ClearsDescendantsAndSelection | CollapseAfter( | unaffected (register list): reset tests; CollapseAfter, leaf-collapse LeftArrow and ToggleLeafExpanded collapse still clear descendants | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowDescendantsIssue1002Tests.cs | LeftArrow_ExpandedLeaf_ClearsDescendantsAndSelection | LeftArrow( | unaffected (register list): reset tests; CollapseAfter, leaf-collapse LeftArrow and ToggleLeafExpanded collapse still clear descendants | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowDescendantsIssue1002Tests.cs | ToggleLeafExpanded_Collapse_ClearsDescendantsAndSelection | ToggleLeafExpanded( | unaffected (register list): reset tests; CollapseAfter, leaf-collapse LeftArrow and ToggleLeafExpanded collapse still clear descendants | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowDescendantsIssue1002Tests.cs | SetLeafChildren_KeylessChildren_HasNoDescendants | ToggleLeafExpanded( | unaffected (register list): reset tests; CollapseAfter, leaf-collapse LeftArrow and ToggleLeafExpanded collapse still clear descendants | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | CollapseAfter_OnNonLeafSegment_HidesDownstreamAndMarksTerminal | CollapseAfter( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | ActivateSegment_ValidAncestorRetainsKeyAndRejectsInvalidStateChanges | ToggleLeafExpanded( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | ReExpand_AfterCollapse_RestoresFullBreadcrumb | CollapseAfter( ReExpand( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | ReExpand_WhenNotCollapsed_IsNoOp | ReExpand( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | CollapseAfter_OnLeafSegment_IsNoOp | CollapseAfter( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | CollapseAfter_WithOutOfRangeIndex_Throws | CollapseAfter( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | ToggleLeafExpanded_WithSubfolders_TogglesState | ToggleLeafExpanded( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | ToggleLeafExpanded_WithoutSubfolders_IsNoOp | ToggleLeafExpanded( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | RightArrow_WhenCollapsed_RestoresFullBreadcrumb | CollapseAfter( RightArrow( | REGISTER: update, add anchored case (P8-T5; R3, AC9) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | RightArrow_WhenExpandedWithLeafSubfolders_ExpandsLeaf | RightArrow( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | RightArrow_WhenFullyExpandedWithoutLeafSubfolders_IsNoOp | RightArrow( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | LeftArrow_WhenLeafExpanded_CollapsesLeafFirst | LeftArrow( ToggleLeafExpanded( | unaffected (register list): leaf collapse step kept | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | LeftArrow_WhenFullyExpanded_HidesTrailingSegment | LeftArrow( | REGISTER: fold into LeftArrow_NeverHidesTrailingSegment (P8-T5; AC7) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | LeftArrow_AtRootSegment_IsNoOp | LeftArrow( | REGISTER: fold into LeftArrow_NeverHidesTrailingSegment (P8-T5; AC7) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | BannerRow_AllTransitions_AreNoOps | CollapseAfter( LeftArrow( ReExpand( RightArrow( ToggleLeafExpanded( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | TrashPseudoRow_AllTransitions_AreNoOps | LeftArrow( ReExpand( RightArrow( ToggleLeafExpanded( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | VisibleSegments_ProjectsEveryState | CollapseAfter( ReExpand( ToggleLeafExpanded( | unaffected: CollapseAfter, ReExpand, banner and trash no-ops, leaf toggle on a non-collapsed row, and Right expanding a fully expanded row are unchanged; non-anchored collapse keeps ReExpand (R3) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | TransitionSequence_CollapseReExpandLeafExpand_KeepsDataImmutable | CollapseAfter( ReExpand( ToggleLeafExpanded( | unaffected (register list): ReExpand and leaf toggle on a non-collapsed row unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | ToggleLeafExpanded_WhileCollapsed_IsNoOp | CollapseAfter( ToggleLeafExpanded( | REGISTER: update, add anchored case (P8-T5; R3, AC9) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionMapTests.cs | GetSelectedFolder_SuggestionRow_YieldsTheLeafFullPath | GetSelectedFolder | unaffected: SelectedRowValue returns RowValue for a leaf-active row; only the shortened or parent-activated case is new | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionMapTests.cs | GetSelectedFolder_ExpandedSubfolderSelection_YieldsTheSubfolderFullPath | GetSelectedFolder | unaffected: SelectedRowValue returns RowValue for a leaf-active row; only the shortened or parent-activated case is new | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionMapTests.cs | GetSelectedFolder_NoSelection_ReturnsNull | GetSelectedFolder | unaffected: SelectedRowValue returns RowValue for a leaf-active row; only the shortened or parent-activated case is new | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionMapTests.cs | IndexAndItemSelection_RoundTrip | GetSelectedFolder | unaffected: SelectedRowValue returns RowValue for a leaf-active row; only the shortened or parent-activated case is new | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionMapTests.cs | NullArguments_AreRejectedExplicitly | GetSelectedFolder | unaffected: SelectedRowValue returns RowValue for a leaf-active row; only the shortened or parent-activated case is new | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionMapTests.cs | TrashToDelete_IsReturnedByteIdentical | GetSelectedFolder | unaffected: SelectedRowValue returns RowValue for a leaf-active row; only the shortened or parent-activated case is new | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionMapTests.cs | PathBVerbatimStrings_WithWildcards_SurviveExactly | GetSelectedFolder | unaffected: SelectedRowValue returns RowValue for a leaf-active row; only the shortened or parent-activated case is new | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionMapTests.cs | EmptyItemSet_YieldsEmptyItemsAndNoMatches | GetSelectedFolder | unaffected: SelectedRowValue returns RowValue for a leaf-active row; only the shortened or parent-activated case is new | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionHighlightTests.cs | (file scope) | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | ClosedNavigation_CommitsSelectableRows_SkipsLabelsAndStopsAtBoundaries | MoveNext MovePrevious | unaffected: open and closed MoveNext/MovePrevious over models with no expanded descendant visit the same rows (the visible order equals the selectable rows) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | OpenNavigation_ChangesPendingWithoutChangingCommittedOrModelSelection | MoveNext | unaffected: open and closed MoveNext/MovePrevious over models with no expanded descendant visit the same rows (the visible order equals the selectable rows) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | CommitPending_CommitsOnceClosesAndClearsTheOpenSnapshot | MoveNext | unaffected: open and closed MoveNext/MovePrevious over models with no expanded descendant visit the same rows (the visible order equals the selectable rows) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | Cancel_RestoresOriginalAndNeverCommitsPending | MoveNext | unaffected: open and closed MoveNext/MovePrevious over models with no expanded descendant visit the same rows (the visible order equals the selectable rows) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | Cancel_WhenOpenedWithoutACommittedSelection_RestoresNoSelection | MoveNext | unaffected: open and closed MoveNext/MovePrevious over models with no expanded descendant visit the same rows (the visible order equals the selectable rows) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | EmptyAndNoSelectableModels_HaveDeterministicNoOpNavigation | MoveNext MovePrevious | unaffected: open and closed MoveNext/MovePrevious over models with no expanded descendant visit the same rows (the visible order equals the selectable rows) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | BreadcrumbLeftAndRightTransitions_DoNotMutateSelectorSession | LeftArrow( RightArrow( | REGISTER: summary cites #1007, assertions unchanged (P10-T4) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | Sequence_CollapseReExpandCollapse_TransitionsDeterministically | CollapseAfter( ReExpand( | unaffected: CollapseAfter and ReExpand on the model are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | Arrows_RightExpandsThenLeftCollapses_UnhandledWhenNothingChanges | LeftArrow( RightArrow( | REGISTER: rewrite (P10-T4; AC7, AC9) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | RightArrow_OnCollapsedRow_ReExpandsBeforeLeafExpansion | CollapseAfter( RightArrow( | REGISTER: rewrite (P10-T4; AC9) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | Arrows_WithNoSelection_AreUnhandled | LeftArrow( RightArrow( | unaffected (register list) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | LeftArrow_WithSubfolderSelected_ResetsSubfolderSelectionAndCollapses | LeftArrow( | REGISTER: summary cites #1007 AC7, assertions kept (P10-T4) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | LeftArrow_RepeatedOnThreeSegmentChain_WalksToRootThenReportsUnhandled | LeftArrow( | REGISTER: rewrite (P10-T4; AC7) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | LeftArrow_WalkFromAnOpenLeafExpansion_ClearsTheExpansionAndStillReachesTheRoot | LeftArrow( RightArrow( | REGISTER: rewrite (P10-T4; AC7) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | CollapseAfter_NonLeafSegment_HidesDownstreamAndClosesLeafExpansion | CollapseAfter( | unaffected: CollapseAfter and ReExpand behaviour and LeafHasSubfolders are unchanged (R3 touches ActiveSegmentHasSubfolders only) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | ReExpand_AfterCollapse_RestoresTheFullChain | CollapseAfter( ReExpand( | unaffected: CollapseAfter and ReExpand behaviour and LeafHasSubfolders are unchanged (R3 touches ActiveSegmentHasSubfolders only) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | CollapseAfter_OnTheLeafIndex_Throws | CollapseAfter( | unaffected: CollapseAfter and ReExpand behaviour and LeafHasSubfolders are unchanged (R3 touches ActiveSegmentHasSubfolders only) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | CollapseAfter_OutOfRangeSegmentIndex_Throws | CollapseAfter( | unaffected: CollapseAfter and ReExpand behaviour and LeafHasSubfolders are unchanged (R3 touches ActiveSegmentHasSubfolders only) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | CollapseAfter_OnPlainRow_ThrowsInvalidOperation | CollapseAfter( | unaffected: CollapseAfter and ReExpand behaviour and LeafHasSubfolders are unchanged (R3 touches ActiveSegmentHasSubfolders only) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | SingleSegmentChain_HasNoCollapsePivotAndObeysAffordanceGate | CollapseAfter( | unaffected: CollapseAfter and ReExpand behaviour and LeafHasSubfolders are unchanged (R3 touches ActiveSegmentHasSubfolders only) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | CollapseAfter_WhenAlreadyCollapsed_MovesThePivot | CollapseAfter( | unaffected: CollapseAfter and ReExpand behaviour and LeafHasSubfolders are unchanged (R3 touches ActiveSegmentHasSubfolders only) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | Reset_RestoresInitialRowState | CollapseAfter( | unaffected: CollapseAfter and ReExpand behaviour and LeafHasSubfolders are unchanged (R3 touches ActiveSegmentHasSubfolders only) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | LeftArrow_QfcMultiSegmentRow_SelectsParentNode | LeftArrow( RightArrow( | REGISTER: rewrite (P10-T4; AC7) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | RightArrow_QfcSelectedParentNode_ExpandsIntoChildren | LeftArrow( RightArrow( | REGISTER: rewrite (P10-T4; AC9) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | ArrowKey_QfcSingleSegmentRow_TakesPreExistingCollapsePath | LeftArrow( RightArrow( | unaffected (register list) | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSubfolderSelectorSessionTests.cs | OpenSelector_InvalidSubfolderIndexes_LeaveSessionAndParentSelectionUnchanged | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSubfolderSelectorSessionTests.cs | AssertDurableSelection | GetSelectedFolder RightArrow( | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterEdgeTests.cs | Route_SelectionChangeWithSubfolderIndex_SelectsTheSubfolder | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterEdgeTests.cs | ActivateSelectorSubfolder_ValidIdentity_CommitsAndEndsSession | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterEdgeTests.cs | ActivateSelectorSubfolder_InvalidInputs_DoNotMutateOpenSession | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | Sequence_ExpandCollapseViaMessages_TransitionsDeterministically | segmentDoubleClick | REGISTER: rewrite (P14-T3; S5, AC9) | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | SetSuggestionsAsync_WhileUpgradeInFlight_ReadbackStaysConsistentAndSelectionSurvives | GetSelectedFolder | unaffected: GetSelectedFolder and closed MoveSelector over leaf-active rows keep their values | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | SetSuggestionFallbacks_IdentityMigration_RebasesOriginalAndPreservesDistinctPending | GetSelectedFolder MoveSelector( | unaffected: GetSelectedFolder and closed MoveSelector over leaf-active rows keep their values | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | SetSuggestionFallbacks_OutOfRangeRetainedIndex_DoesNotFallback | GetSelectedFolder | unaffected: GetSelectedFolder and closed MoveSelector over leaf-active rows keep their values | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | SetSuggestionFallbacks_NonselectableRetainedIndex_DoesNotFallback | GetSelectedFolder | unaffected: GetSelectedFolder and closed MoveSelector over leaf-active rows keep their values | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterReplaceItemsTests.cs | ReplaceItemsPreservingSession_WithPendingHighlight_DoesNotCommitIt | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterReplaceItemsTests.cs | ReplaceItemsPreservingSession_WhileClosed_LeavesTheSessionClosed | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterReplaceItemsTests.cs | HighlightRow_OnAnOpenSession_MovesPendingWithoutChangingTheSelectedFolder | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | (file scope) | unhandledArrow | unaffected: closed-session arrowKey and GetSelectedFolder on leaf-active rows keep their behaviour; the file-scope match is a message constant | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | SelectedFolder_ChainResolvedToFullPath_RemainsPresentedStem | GetSelectedFolder | unaffected: closed-session arrowKey and GetSelectedFolder on leaf-active rows keep their behaviour; the file-scope match is a message constant | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | Route_SegmentDoubleClick_ProducesCollapsedRenderPayload | segmentDoubleClick | REGISTER: rewrite in place to S5 no-op (P14-T3) | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | Route_RightArrow_ExpandsWhenExpandable | arrowKey | unaffected (register list): closed-session Right expands the leaf | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | Route_RightArrow_NothingToExpand_ReportsUnhandledRight | arrowKey | unaffected (register list): closed-session Right on a childless leaf stays unhandled | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | Route_ThemeChange_EchoesThemeAndReRenders | arrowKey | unaffected: closed-session arrowKey and GetSelectedFolder on leaf-active rows keep their behaviour; the file-scope match is a message constant | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | ArrowAsync_QfcRightOnSelectedParentNode_RoutesChildExpansion | GetSelectedFolder | REGISTER: rewrite in place (P10-T5; AC9) | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbDescendantOrderingIssue997Tests.cs | CollapseDuringFetch_DiscardsLateChildren | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbDescendantOrderingIssue997Tests.cs | ReplacePopulationDuringFetch_DiscardsOldChildren | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbRouterSelectionConcurrencyTests.cs | UpgradeStarted_ClosedMoveToDuplicateRow_RemainsSelectedAfterReplacement | MoveSelector( | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbRouterSelectionConcurrencyTests.cs | UpgradeStarted_OpenPendingMoveToDuplicateRow_CommitsExactMovedRow | GetSelectedFolder MoveSelector( | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbRouterSelectionConcurrencyTests.cs | UpgradeStarted_DirectItemSelectionOfAnotherPath_SurvivesReplacement | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderSuggestionTreeStateTests.cs | RightArrow_OnLeaf_IsNoOp | RightArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderSuggestionTreeStateTests.cs | LeftArrow_OnLeaf_IsNoOp | LeftArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderSuggestionTreeStateTests.cs | RightArrow_ExpandsCollapsedExpandableRoot | RightArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderSuggestionTreeStateTests.cs | RightArrow_OnAlreadyExpandedNode_IsNoOp | RightArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderSuggestionTreeStateTests.cs | LeftArrow_OnAlreadyCollapsedNode_IsNoOp | LeftArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderSuggestionTreeStateTests.cs | LeftArrow_CollapsesExpandedRoot | LeftArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderSuggestionTreeStateTests.cs | Transitions_OnBannerRow_AreAllNoOps | LeftArrow( RightArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderSuggestionTreeStateTests.cs | Transitions_OnNullNode_DoNotThrow | LeftArrow( RightArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderTreeStateModelTests.cs | RightArrow_ExpandsHighlightedCollapsedParent | RightArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderTreeStateModelTests.cs | LeftArrow_CollapsesHighlightedExpandedParent | LeftArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderTreeStateModelTests.cs | RightArrow_OnLeafOrAlreadyExpanded_IsNoOp | RightArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderTreeStateModelTests.cs | LeftArrow_OnLeafOrAlreadyCollapsed_IsNoOp | LeftArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderTreeStateModelTests.cs | Arrows_WithNoHighlight_AreNoOp | LeftArrow( RightArrow( | unaffected: LeftArrow/RightArrow of FolderSuggestionTreeState and FolderTreeStateModel, which this change does not edit | +| UtilitiesCS.Test/ReusableTypeClasses/AsyncQueue_Tests.cs | Enqueue_ThenMoveNextAsync_YieldsQueuedItem | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| UtilitiesCS.Test/ReusableTypeClasses/AsyncQueue_Tests.cs | EmptyQueue_MoveNextAsync_WaitsUntilItemIsEnqueued | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| UtilitiesCS.Test/ReusableTypeClasses/AsyncQueue_Tests.cs | CanceledEnumeration_MoveNextAsync_ThrowsOperationCanceledException | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| UtilitiesCS.Test/ReusableTypeClasses/AsyncQueue_Tests.cs | ConcurrentEnqueue_AndSequentialDequeue_PreserveAllItems | MoveNext | unaffected: IEnumerator.MoveNext / MoveNextAsync of unrelated types, not the selection session | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.cs | OutboundPayloads_BeforeInitialization_AreQueuedAndFlushedInOrder | arrowKey | unaffected (register list): queueing only | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | (file scope) | segmentDoubleClick | unaffected: #498 range guard moves with HandleSegmentDoubleClick and keeps rejecting out-of-range and negative indexes without a transition | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | SegmentDoubleClick_IndexAboveRange_RejectedWithoutTransition | segmentDoubleClick | unaffected: #498 range guard moves with HandleSegmentDoubleClick and keeps rejecting out-of-range and negative indexes without a transition | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | SegmentDoubleClick_NegativeIndex_RejectedWithoutTransition | segmentDoubleClick | unaffected: #498 range guard moves with HandleSegmentDoubleClick and keeps rejecting out-of-range and negative indexes without a transition | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | SegmentDoubleClick_IndexAboveRange_ViaHostEvent_DoesNotThrowAndLeavesStateUnchanged | segmentDoubleClick | unaffected: #498 range guard moves with HandleSegmentDoubleClick and keeps rejecting out-of-range and negative indexes without a transition | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | SegmentDoubleClick_NegativeIndex_ViaHostEvent_DoesNotThrowAndLeavesStateUnchanged | segmentDoubleClick | unaffected: #498 range guard moves with HandleSegmentDoubleClick and keeps rejecting out-of-range and negative indexes without a transition | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | SegmentDoubleClick_ValidIndex_ViaHostEvent_CollapsesRowAndPostsRender | segmentDoubleClick | REGISTER: update (P8-T6; S10) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | SegmentDoubleClick_BannerRow_ViaHostEvent_ShortCircuitsBeforeRangeCheck | segmentDoubleClick | unaffected (register list): banner short-circuit moves with the guard (R9) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | SegmentDoubleClick_OnNonLeafSegment_CollapsesAndReRenders | segmentDoubleClick | REGISTER: update, last row-scoped render (P8-T3; S10) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | ArrowKeyRight_ThenLeft_ExpandsAndCollapses | arrowKey | unaffected (register list): Right expands and Left collapses an expanded row, both kept | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | HandleArrowKey_LeftOnMultiSegmentRow_ActivatesParentSegment | arrowKey LeftArrow( | REGISTER: rewrite (P8-T3; AC7) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | HandleArrowKey_RepeatedLeft_WalksToRootThenFallsThroughToExistingBehavior | arrowKey ReExpand( | REGISTER: rewrite (P8-T3; AC7) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | HandleArrowKey_RightOnActivatedParent_ExpandsViaSingleImmediateSubfolderCall | arrowKey segmentDoubleClick | REGISTER: update to AC9 shape (P8-T3) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | HandleArrowKey_RightAfterExpansion_DescendsByChildActivation | arrowKey | REGISTER: update (P8-T3; AC5, AC14) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | ArrowKey_SingleSegmentRow_TakesPreExistingCollapsePath | arrowKey | unaffected (register list): single-segment row keeps the leaf collapse path | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | Boundary_EfcLeftAtRootAndRightOnChildlessNode_RemainSilentNoOps | arrowKey | REGISTER: update (P8-T3; AC7, AC6) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | ArrowKeyUp_AtTopSelectableRow_PostsFocusSearchAndRaisesEvent | arrowKey | unaffected (register list): Up on first entry keeps focus search (AC2) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | ArrowKeyDown_SelectsNextSelectableRow | arrowKey | unaffected (register list): addresses an unselected row, R2 keeps row stepping | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | ArrowKeyUp_OnNonTopRow_SelectsPreviousRowWithoutFocusSearch | arrowKey | unaffected (register list): R2 | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | LeafExpandToggle_OnCollapsedRow_ReExpandsWithoutProviderQuery | segmentDoubleClick | REGISTER: rewrite (P8-T4; AC9) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | ArrowKeyRight_WhenCollapsed_ReExpandsWithoutProviderQuery | arrowKey segmentDoubleClick | REGISTER: rewrite (P8-T4; AC9) | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | ArrowKey_UnknownKey_IsLoggedNoOp | arrowKey | REGISTER: update key Home to Insert (P6-T2; AC11) | +| QuickFiler.Test/Controllers/QfcItemController.EventHandlersTests.cs | CboFolders_SelectedIndexChanged_StoresSelectedFolder | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.cs | AssignFolderComboBox_WhenNoPredeterminedFolder_SelectsTopSuggestionViaViewer | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.cs | AssignFolderComboBox_WhenPredeterminedFolderPresent_PreselectsThatFolder | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.cs | AssignFolderComboBox_WhenSingleSuggestionNoPredeterminedMatch_SelectsIndexZero | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs | AssignFolderComboBox_WhenArchiveRootedPredeterminedFolder_PreselectsThatFolder | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs | AssignFolderComboBox_WhenEmptyArchiveRootAndLeadingSeparator_PreselectsProjectedFolder | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs | AssignFolderComboBox_WhenArchiveRootPathThrows_DegradesToIndexFallbackWithoutThrowing | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderSuggestionsTests.cs | AssignFolderComboBox_HandsPredictorRowArrayToSetFolderSuggestions | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderSuggestionsTests.cs | AssignFolderComboBox_RetainsSetFolderItemsAndIndexOneSelection | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.FolderSuggestionsTests.cs | AssignFolderComboBox_PredeterminedFolder_PreselectsByNameAndStillPopulates | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Controllers/QfcItemController.SearchFocusRegressionTests.cs | SearchThenCancel_LeavesTheCachedFolderAtThePreSearchCommittedValue | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorProbabilityTests.cs | SetSuggestions_SuccessfulUpgradeRetainsScoreAndLatestSelection | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | InboundDoubleClick_PostsCollapsedRenderJson | segmentDoubleClick | REGISTER: rewrite in place to selectorSegmentDoubleClick (P14-T3; S5) | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | InboundSelectionMessage_RaisesSelectionChangedWithMappedPath | GetSelectedFolder | unaffected: selection mapping on leaf-active rows is unchanged | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | UnhandledRightArrow_RaisesUnhandledArrowRight | arrowKey | REGISTER: update in place (P12-T2; AC10, AC6) | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | UnhandledLeftArrow_RaisesUnhandledArrowLeft | unhandledArrow | REGISTER: update in place (P12-T2; AC10) | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | ArrowMessages_RaiseSyntheticFolderKeyDown | arrowKey unhandledArrow | REGISTER: update in place (P12-T2; AC10) | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | Clear_EmptiesSelectionStateAndPostsEmptyRender | GetSelectedFolder | unaffected: selection mapping on leaf-active rows is unchanged | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | SetSuggestions_SyncFacade_PopulatesImmediatelyThenUpgradesPreservingSelection | GetSelectedFolder | unaffected: selection mapping on leaf-active rows is unchanged | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | SelectItem_KnownItemSelects_UnknownItemIsNoOp | GetSelectedFolder | unaffected: selection mapping on leaf-active rows is unchanged | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | SelectRow_WhileSuggestionsUpgradeInFlight_DoesNotThrowAndAppliesSelection | GetSelectedFolder | unaffected: selection mapping on leaf-active rows is unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDescendantActivationIssue997Tests.cs | ActivateDescendant_NotifiesAndClosesPopupOnce | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDescendantActivationIssue997Tests.cs | ToggleDescendant_DoesNotRaiseSelectionChanged | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDropDownIntegrationTests.cs | SetFolderDroppedDownFalse_RequestsOneUncommittedCloseAndRollback | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDropDownIntegrationTests.cs | NativeAutomaticClose_RestoresOriginalCommittedIdentityWithoutPendingPublicationAndReturnsFocusOnce | GetSelectedFolder HandleSelectorKey | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDropDownReadinessTests.cs | OpenAsync_ReadinessFailureRollsBackDisposesPartialSurfaceAndReturnsFocusOnce | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDropDownReadinessTests.cs | CacheSelectorStateAndOpenSession | GetSelectedFolder HandleSelectorKey | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDropDownSearchIntegrationTests.cs | PresentFolderSearchResults_EmptyResultSet_DoesNotThrowOpenOrMutateSelection | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDuplicateIdentityIntegrationTests.cs | ClosedDown_DuplicateSuggestionAndRecentCommitsRecentOccurrence | GetSelectedFolder HandleSelectorKey | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDuplicateIdentityIntegrationTests.cs | OpenDownThenEnter_DuplicateSuggestionAndRecentCommitsPendingOccurrence | GetSelectedFolder HandleSelectorKey | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDuplicateIdentityIntegrationTests.cs | ActivateSelector_SecondPublishedIdentityCommitsExactDuplicateOccurrence | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbDuplicateIdentityIntegrationTests.cs | CollapsedReadback_SecondDuplicateSuggestionRetainsItsProbability | GetSelectedFolder HandleSelectorKey | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbPendingOpenCloseTests.cs | ToggleAndEscapeWhileOpenIsPending_EachClosesHostExactlyOnce | HandleSelectorKey | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | ClosedDown_CommitsNextSelectableAndRaisesOneSelection | GetSelectedFolder | unaffected: closed and open Up/Down/Enter/Escape and mouse activation are unchanged (AC10 keeps the closed Up/Down commit) | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | OpenDown_ChangesPendingOnlyThenEnterCommitsAndCloses | GetSelectedFolder | unaffected: closed and open Up/Down/Enter/Escape and mouse activation are unchanged (AC10 keeps the closed Up/Down commit) | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | EscapeAndUncommittedClose_RestoreOpeningSelectionWithoutNotification | GetSelectedFolder | unaffected: closed and open Up/Down/Enter/Escape and mouse activation are unchanged (AC10 keeps the closed Up/Down commit) | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | MouseActivation_CommitsStableIdentityExactlyOnce | GetSelectedFolder | unaffected: closed and open Up/Down/Enter/Escape and mouse activation are unchanged (AC10 keeps the closed Up/Down commit) | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | InvalidSelectorMessage_IsNoOpAndDoesNotRaiseTransitions | GetSelectedFolder | unaffected: closed and open Up/Down/Enter/Escape and mouse activation are unchanged (AC10 keeps the closed Up/Down commit) | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | ExistingLeftAndRightMessages_StillForwardOnce | arrowKey | REGISTER: update in place, no added lines (P12-T2; AC10) | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | InboundValidSelectorMessages_RouteToggleKeyAndActivationBranches | GetSelectedFolder | unaffected: closed and open Up/Down/Enter/Escape and mouse activation are unchanged (AC10 keeps the closed Up/Down commit) | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | InboundSelectorKeyUp_MovesPendingPastSeparatorAndClampsWithoutDuplicatePublication | GetSelectedFolder HandleSelectorKey | unaffected: closed and open Up/Down/Enter/Escape and mouse activation are unchanged (AC10 keeps the closed Up/Down commit) | +| QuickFiler.Test/Viewers/BreadcrumbSubfolderActivationTests.cs | OpenSelector_InvalidIdentityAndIndexes_DoNotPublishCloseFocusOrMutate | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbSubfolderActivationTests.cs | OpenSelector_SubfolderActivationForPlainRow_IsDeterministicNoOp | GetSelectedFolder | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/BreadcrumbSubfolderActivationTests.cs | OpenSelector_SubfolderActivation_PublishesOneDurableRenderAndNoLegacySelectionChange | GetSelectedFolder HandleSelectorKey | unaffected (register list): D-Q29 pends the committed subfolder on reopen | +| QuickFiler.Test/Viewers/BreadcrumbSubfolderActivationTests.cs | AssertDurableActivation | GetSelectedFolder HandleSelectorKey | unaffected: GetSelectedFolder changes only for a shortened or parent-activated row (S9); leaf-active rows and descendant selections keep RowValue/TryDescendantValue; open and closed Up/Down/Enter/Escape over rows with no visible descendants keep their row walk; HandleSelectorKey gains an optional parameter, so existing calls are unchanged | +| QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs | SelectorKeys_PreventBrowserScrollingAndPostNativeKeyMessages | selectorKeys | REGISTER: update (P16-T6; AC11) | +| QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs | RenderReceiver_OwnsSelectedChildExpandedAndCollapsedProjection | onArrow | REGISTER: update (P16-T6; AC4) | +| QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs | LeftAndRightBreadcrumbMessages_RemainSupported | arrowKey unhandledArrow | REGISTER: update (P16-T6; S6) | +| tests/quickfiler-breadcrumb/issue-997-common.test.mjs | row cells retain literal names, title, truncation and host percentage | segmentDoubleClick | REGISTER: update to selectorSegmentDoubleClick (P16-T3; S5) | +| tests/quickfiler-breadcrumb/issue-997-common.test.mjs | arrow ${direction} routes ${type} state through host contract | arrowKey unhandledArrow | REGISTER: twelve generated cases expect arrowKey (P16-T3; S6) | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-analyzers.2026-10-23T13-40.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-analyzers.2026-10-23T13-40.md new file mode 100644 index 000000000..95ffb3967 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-analyzers.2026-10-23T13-40.md @@ -0,0 +1,8 @@ +# Baseline Toolchain Analyzers (Issue #1007) + +Timestamp: 2026-10-23T13-40 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true (MSBuild resolved through vswhere; console logger /v:minimal /clp:Summary; log kept under SCRATCH) +EXIT_CODE: 0 +Output Summary: +- `0 Warning(s)`, `0 Error(s)`. +- Warnings whose path is a PROD-MODIFY file: none. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-bootstrap.2026-10-23T13-39.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-bootstrap.2026-10-23T13-39.md new file mode 100644 index 000000000..a3cb3b403 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-bootstrap.2026-10-23T13-39.md @@ -0,0 +1,11 @@ +# Baseline Toolchain Bootstrap (Issue #1007) + +Timestamp: 2026-10-23T13-39 +Command: dotnet --version; dotnet tool run csharpier --version; packages.config restore check against packages/; git status --porcelain (helper script under SCRATCH, read-only, no restore re-run) +EXIT_CODE: 0 +Output Summary: +- global.json pins SDK 8.0.205; `.dotnet-sdk/sdk` holds 8.0.205; `dotnet --version` reports 8.0.205. +- `dotnet tool run csharpier --version` reports 1.2.6. +- packages/ holds 173 package folders; every package id and version listed in a first-party packages.config resolves to a packages/ folder (missing=0). +- MSBuild resolved through vswhere (Visual Studio 18 Community, MSBuild\Current\Bin\MSBuild.exe); dotnet-coverage resolved from the user tool path; node v24.14.0. +- `git status --porcelain`: no tracked source change. The only entries are the plan checkbox for P0-T1 and the untracked evidence/baseline folder written by Phase 0. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-format.2026-10-23T13-39.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-format.2026-10-23T13-39.md new file mode 100644 index 000000000..63b2fa33f --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-format.2026-10-23T13-39.md @@ -0,0 +1,6 @@ +# Baseline Toolchain Format (Issue #1007) + +Timestamp: 2026-10-23T13-39 +Command: dotnet tool run csharpier check . +EXIT_CODE: 0 +Output Summary: read-only check only (no format applied). Success line: `Checked 1675 files in 5759ms.` No drifting files. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-typecheck.2026-10-23T13-41.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-typecheck.2026-10-23T13-41.md new file mode 100644 index 000000000..4464dbf2e --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/baseline-toolchain-typecheck.2026-10-23T13-41.md @@ -0,0 +1,6 @@ +# Baseline Toolchain Type Check (Issue #1007) + +Timestamp: 2026-10-23T13-41 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true (MSBuild resolved through vswhere; no /p:Nullable=enable; log kept under SCRATCH) +EXIT_CODE: 0 +Output Summary: `0 Error(s)`, `0 Warning(s)`. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/phase0-instructions-read.2026-10-23T13-38.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/phase0-instructions-read.2026-10-23T13-38.md new file mode 100644 index 000000000..9b7e70f9a --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/baseline/phase0-instructions-read.2026-10-23T13-38.md @@ -0,0 +1,23 @@ +# Phase 0 Instructions Read (Issue #1007) + +Timestamp: 2026-10-23T13-38 +Command: read-only file reads (no toolchain command) +EXIT_CODE: 0 +Branch: bug/efc-arrow-keys-skip-subfolders-1007 +Policy Order: CLAUDE.md -> .claude/rules/general-code-change.md -> .claude/rules/general-unit-test.md -> .claude/rules/csharp.md (policy-compliance-order), then the supporting rule, skills, spec, transcript and research. + +Files read, in order: + +1. `CLAUDE.md` +2. `.claude/rules/general-code-change.md` +3. `.claude/rules/general-unit-test.md` +4. `.claude/rules/csharp.md` +5. `.claude/rules/plan-acceptance-gates.md` +6. `.claude/skills/execution-environment/SKILL.md` +7. `.claude/skills/evidence-and-timestamp-conventions/SKILL.md` +8. `.claude/skills/acceptance-criteria-tracking/SKILL.md` +9. `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/spec.md` +10. `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/grill-transcript.md` +11. `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/research/2026-10-23T10-40-arrow-key-subfolder-navigation-research.md` + +Output Summary: all eleven files named by P0-T1 were read. Work mode is full-bug; the acceptance-criteria source is spec.md (AC1 to AC14), mirrored into issue.md on check-off per the delegation. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/other/preflight-fixes.2026-10-23T13-34.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/other/preflight-fixes.2026-10-23T13-34.md new file mode 100644 index 000000000..98bf426ae --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/other/preflight-fixes.2026-10-23T13-34.md @@ -0,0 +1,31 @@ +# Preflight Fixes — Issue #1007 + +Timestamp: 2026-10-23T13-34 +Plan: docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/plan.2026-10-22T13-03.md +Directive: PREFLIGHT VALIDATION ONLY (single pass; task text edited only; no task added, removed, or reordered) + +## Fixes + +1. File Sets, EXCLUDED bullet | count and citation correction + - Before: "the four not-to-be-extended 1002/439/Queue/provider test files the spec lists under Boundaries". + - After: "the five not-to-be-extended test files the spec lists under Boundaries", followed by the five repository-relative paths. + - Reason: spec.md (Boundaries and invariants, 500-line limit bullet) lists five such files: BreadcrumbBridgeRouterIssue1002Tests.cs, BreadcrumbRowDescendantsIssue1002Tests.cs, BreadcrumbBridgeRouterQueueTests.cs, BreadcrumbBridgeRouterIssue439Tests.cs, OutlookFolderHierarchyProviderTests.cs. Each exists in the tree. The assertion (these files stay out of the diff) is unchanged. + +2. [P10-T3] | validator-blind compile-ordering clarification + - Added: page keys with a size are delivered as a `selectorKey` JSON message carrying `pageSize`, because the `pageSize` parameter of `HandleSelectorKey` lands in P11-T6. + - Reason: `BreadcrumbBridgeCoordinator.HandleSelectorKey(BreadcrumbSelectorKey key)` currently takes no page size, and Phase 1 does not declare one. A direct two-argument call in `SelectorKeyPageDown_Open_MovesPendingByPageSize` would not compile at P10-T6, which contradicts R10. The JSON route compiles and fails at run time as R10 intends. The asserted behavior is unchanged. + +3. [P10-T6] | class-name correction + - Before: "the five edited classes". + - After: "the four edited classes `BreadcrumbStateModelTests` (whose partials include `BreadcrumbStateModelSequenceTests.cs`), `BreadcrumbSelectionSessionTests`, `BreadcrumbSelectorMessagesTests`, and `FolderBreadcrumbBridgeRouterTests`". + - Reason: `BreadcrumbStateModelSequenceTests.cs` declares `partial class BreadcrumbStateModelTests`, so the five edited files form four test classes; a class filter named `BreadcrumbStateModelSequenceTests` would match nothing. The run set is unchanged. + +4. [P18-T1] | validator-blind merge-base residue (scope gate) + - Added `docs/features/potential/promoted/2026-10-22-efc-arrow-keys-skip-subfolders.md` (added by the promotion commit before Phase 0) to the permitted path set. + - Reason: `git diff --name-status` from the merge base of `origin/main` and `HEAD` already lists this promotion file (status A) before any plan task runs, so without it the gate could not pass. The fence still covers every file the plan's tasks change. + +## Checks of the fixes against their own rules + +- No fix cites a line number or a commit SHA (G11, G12). +- No fix adds, removes, or reorders a task, and none changes what an acceptance condition asserts. +- The fix prose uses neutral wording that follows `.claude/rules/tonality.md`. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/other/preflight-fixes.2026-10-25T23-50.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/other/preflight-fixes.2026-10-25T23-50.md new file mode 100644 index 000000000..fc9f6920f --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/other/preflight-fixes.2026-10-25T23-50.md @@ -0,0 +1,23 @@ +# Preflight Fixes (Issue #1007, remediation cycle 1) + +Timestamp: 2026-10-25T23-50 +Plan: docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/remediation-plan.2026-10-25T23-50.md +Validator: `validate_orchestration_artifacts` (plan) returned ok with no warnings before and after the fix. + +## Fixes + +1. [P5-T15] | literal scoping (trivial) | The task counted "AC boxes" in `spec.md` and `issue.md` without naming the section. `issue.md` also carries unchecked checkboxes under `## Logs / Screenshots`, `## Impact / Severity`, `## Proposed Fix / Validation Ideas` and `## Next Step`, so a whole-file count could not show 0 unchecked. The task now counts the `## Acceptance Criteria` section of each file. The asserted value (14 checked, 0 unchecked) is unchanged. + +## Citations re-derived during preflight (no defect found) + +- `QuickFiler/Resources/FolderBreadcrumb.html`: one `` tags +EXIT_CODE: 0 +Output Summary: Exactly 1 match. It sits inside the ``. Its rule declares `background: var(--bc-sel-back)` and `color: var(--bc-sel-fore)`. + +## Match and enclosing rule + +``` +.sub.selected, .sub.active { background: var(--bc-sel-back); color: var(--bc-sel-fore); } +``` + +| Check | Result | +|---|---| +| Matches for `.sub.active` | 1 | +| Inside the ``) | +| Declarations | `background: var(--bc-sel-back)`; `color: var(--bc-sel-fore)` | +| Theme coverage | both variables are redefined by the `:root[data-theme="dark"]` block and the `prefers-color-scheme: dark` block of the same stylesheet | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/scope-fences.2026-10-25T22-44.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/scope-fences.2026-10-25T22-44.md new file mode 100644 index 000000000..4f9048461 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/scope-fences.2026-10-25T22-44.md @@ -0,0 +1,70 @@ +# Scope Fences (Issue #1007, P18-T1) + +Timestamp: 2026-10-25T22-44 +Command: git diff --name-status plus git status --porcelain --untracked-files=all, each path classified against the plan's File Sets by a helper under SCRATCH +EXIT_CODE: 0 +Output Summary: 91 distinct paths. Classes: 21 PROD-MODIFY, 5 PROD-CREATE, 14 TEST-CREATE, 14 DISPOSITION-REGISTER, 4 project files, 2 named others (the browser harness and the promoted potential entry), and 31 paths under `/`. 0 paths outside the fence and 0 EXCLUDED paths. The only project files are the four legacy project files `UtilitiesCS/UtilitiesCS.csproj`, `QuickFiler/QuickFiler.csproj`, `UtilitiesCS.Test/UtilitiesCS.Test.csproj` and `QuickFiler.Test/QuickFiler.Test.csproj`. The merge base is recorded by derivation, not by commit identifier. + +Note: `PROD-MODIFY` lists 21 files in the plan; every one appears in the diff. `UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs` and `QuickFiler/Resources/FolderBreadcrumb.js` are among them. The Disposition Register files `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs` and `FolderBreadcrumbBridgeRouterInFlightTests.cs` are partials of register classes. + +| Status | Class | Path | +|---|---|---| +| A | NAMED-OTHER | docs/features/potential/promoted/2026-10-22-efc-arrow-keys-skip-subfolders.md | +| M | NAMED-OTHER | tests/quickfiler-breadcrumb/issue-997-browser-harness.mjs | +| M | PROJECT-FILE | QuickFiler.Test/QuickFiler.Test.csproj | +| M | PROJECT-FILE | QuickFiler/QuickFiler.csproj | +| M | PROJECT-FILE | UtilitiesCS.Test/UtilitiesCS.Test.csproj | +| M | PROJECT-FILE | UtilitiesCS/UtilitiesCS.csproj | +| ?? | PROD-CREATE | QuickFiler/Controllers/BreadcrumbBridgeRouter.Navigation.cs | +| ?? | PROD-CREATE | QuickFiler/Viewers/BreadcrumbBridgeCoordinator.Keyboard.cs | +| ?? | PROD-CREATE | UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.Tree.cs | +| ?? | PROD-CREATE | UtilitiesCS/OutlookObjects/Folder/BreadcrumbVisibleOrder.cs | +| ?? | PROD-CREATE | UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Navigation.cs | +| M | PROD-MODIFY | QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs | +| M | PROD-MODIFY | QuickFiler/Controllers/BreadcrumbBridgeRouter.cs | +| M | PROD-MODIFY | QuickFiler/Controllers/BreadcrumbBridgeRouter.Descendants.cs | +| M | PROD-MODIFY | QuickFiler/Controllers/BreadcrumbBridgeRouter.Selection.cs | +| M | PROD-MODIFY | QuickFiler/Resources/FolderBreadcrumb.js | +| M | PROD-MODIFY | QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.Descendants.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectorMessages.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.Row.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs | +| M | PROD-MODIFY | UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs | +| ?? | TEST-CREATE | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.cs | +| ?? | TEST-CREATE | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs | +| ?? | TEST-CREATE | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.Navigation.cs | +| ?? | TEST-CREATE | QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs | +| ?? | TEST-CREATE | QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs | +| ?? | TEST-CREATE | tests/quickfiler-breadcrumb/issue-1007-keys.test.mjs | +| ?? | TEST-CREATE | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeJsKeysIssue1007Tests.cs | +| ?? | TEST-CREATE | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbEmptyExpanderIssue1007Tests.cs | +| ?? | TEST-CREATE | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTreeIssue1007Tests.cs | +| ?? | TEST-CREATE | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbVisibleOrderTests.cs | +| ?? | TEST-CREATE | UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.cs | +| ?? | TEST-CREATE | UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderIssue1007Tests.cs | +| M | DISPOSITION-REGISTER | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | +| M | DISPOSITION-REGISTER | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | +| M | DISPOSITION-REGISTER | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | +| M | DISPOSITION-REGISTER | QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | +| M | DISPOSITION-REGISTER | QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | +| M | DISPOSITION-REGISTER | QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs | +| M | DISPOSITION-REGISTER | tests/quickfiler-breadcrumb/issue-997-common.test.mjs | +| M | DISPOSITION-REGISTER | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | +| M | DISPOSITION-REGISTER | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | +| M | DISPOSITION-REGISTER | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs | +| M | DISPOSITION-REGISTER | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | +| M | DISPOSITION-REGISTER | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | +| M | DISPOSITION-REGISTER | UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | +| M | DISPOSITION-REGISTER | UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | +| A or ?? | FEATURE | 31 paths under `/` (issue, spec, plan, grill transcript, research and evidence Markdown) | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/scope-fences.2026-10-26T00-06.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/scope-fences.2026-10-26T00-06.md new file mode 100644 index 000000000..e6246f170 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/scope-fences.2026-10-26T00-06.md @@ -0,0 +1,30 @@ +# Scope Fences (Issue #1007, remediation cycle 1, P5-T10) + +Timestamp: 2026-10-26T00-06 +Command: git status --porcelain --untracked-files=all; git diff --name-status HEAD +EXIT_CODE: 0 +Output Summary: PASS. All 8 tracked changes and 2 new test files are WRITE-SET paths, and every other untracked path is this plan or evidence under `/evidence/`. No C# production file (`*.cs` outside a `*.Test` project) and no `.claude/` path is listed. The only production change is the embedded resource `QuickFiler/Resources/FolderBreadcrumb.html`, which is in WRITE-SET. + +## Tracked changes (`git diff --name-status HEAD`) + +| Status | Path | WRITE-SET | +|---|---|---| +| M | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs | yes | +| M | QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | yes | +| M | QuickFiler.Test/QuickFiler.Test.csproj | yes | +| M | QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs | yes | +| M | QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs | yes | +| M | QuickFiler/Resources/FolderBreadcrumb.html | yes | +| M | UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | yes | +| M | UtilitiesCS.Test/UtilitiesCS.Test.csproj | yes | + +## Untracked paths (`git status --porcelain --untracked-files=all`) + +| Path | Classification | +|---|---| +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.Unhandled.cs | WRITE-SET (new) | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.Unanchored.cs | WRITE-SET (new) | +| `/remediation-plan.2026-10-25T23-50.md` | WRITE-SET (this plan) | +| 18 files under `/evidence/` (`other/`, `qa-gates/`, `regression-testing/`, `remediation-baseline/`) | WRITE-SET (evidence) | + +`spec.md` and `issue.md` are not yet listed because P5-T13 and P5-T14 run after this task. Both are WRITE-SET paths. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/self-review.2026-10-25T23-14.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/self-review.2026-10-25T23-14.md new file mode 100644 index 000000000..056302100 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/self-review.2026-10-25T23-14.md @@ -0,0 +1,67 @@ +# Self-Review (Issue #1007, P19-T23) + +Timestamp: 2026-10-25T23-14 +Command: checklist walk of the policy-audit, code-review and feature-audit sections against the branch diff from the merge base of origin/main and HEAD, plus the uncommitted and untracked work (scope-fences.2026-10-25T22-44.md path set) +EXIT_CODE: 0 +Output Summary: 44 rows: 40 PASS, 0 FAIL, 4 N/A. No FAIL row, so no fix or loop re-run was needed after this walk. + +Template deviation: `docs/features/templates/policy_audit/` does not exist in TaskMaster, and the MCP template resolver named by `.claude/skills/policy-audit-template-usage/SKILL.md` is not in this executor's tool set. As P19-T23 directs, the policy-audit walk uses the skill's canonical section list (`## 1` to `## 10`). The code-review and feature-audit walks use the review dimensions of the plan's Pre-Mortem and the spec's acceptance criteria. This file is a self-review, not the feature review's policy-audit artifact. + +## Policy audit (canonical sections) + +| # | Section / check | Result | Evidence | +|---|---|---|---| +| 1.1 | Unit tests independent, isolated, fast, deterministic | PASS | csharp-isolation.2026-10-25T23-12.md (10 runs, 0 failures); no sleeps or clock reads (test-policy-audit.2026-10-25T23-12.md) | +| 1.2 | Arrange-Act-Assert structure and documented intent | PASS | test-policy-audit.2026-10-25T23-12.md (AAA markers in every file; every new test has a summary) | +| 1.3 | No temporary files and no external dependencies | PASS | 0 hits for `Path.GetTemp`, `File.Write` and `File.Create`; providers and messengers mocked | +| 1.4 | Positive, negative, edge, error and state-transition scenarios | PASS | Test Name Register guards and negatives (unknown row, invalid page size, first segment, zero children, detached node) | +| 2.1 | Simplicity and separation of concerns | PASS | pure `BreadcrumbVisibleOrder`; session, router and coordinator responsibilities kept in their partials | +| 2.2 | File size at most 500 lines | PASS | file-sizes.2026-10-25T23-12.md | +| 2.3 | Fail fast; no silent broad catch | PASS | constructor guards on the new selector messages; the bridge `segmentDoubleClick` no-op is logged at debug level (log4net) | +| 2.4 | Public API compatibility | PASS | additive members only; `HandleSelectorKey(BreadcrumbSelectorKey)` keeps its one-argument signature, and the page size goes through a private core (deviation from the R8 wording, recorded in qfc-open-pass-after.2026-10-25T22-19.md) | +| 2.5 | Supporting documents updated | PASS | plan checked off; spec.md and issue.md AC boxes; evidence set | +| 3.1 | CSharpier formatting | PASS | final-toolchain-format.2026-10-25T23-07.md | +| 3.2 | Analyzers with /t:Rebuild | PASS | final-toolchain-analyzers.2026-10-25T23-07.md (0 warnings, 0 errors) | +| 3.3 | Nullable: `#nullable enable` in new production files; TreatWarningsAsErrors build without /p:Nullable=enable | PASS | final-toolchain-typecheck.2026-10-25T23-07.md | +| 3.4 | `` for every new C# file | PASS | test-policy-audit.2026-10-25T23-12.md; QuickFiler.csproj and UtilitiesCS.csproj entries for the five PROD-CREATE files | +| 3.5 | Thread affinity (I6): no `ConfigureAwait(false)` and no lock in EFC router files | PASS | thread-affinity.2026-10-25T22-44.md | +| 4.1 | MSTest + Moq + FluentAssertions | PASS | test-policy-audit.2026-10-25T23-12.md | +| 4.2 | Parallel execution kept; no [DoNotParallelize], Workers=1 or retries | PASS | parallel runsettings in every run; 0 hits for `DoNotParallelize` | +| 5.1 | Repository line at least 80 percent and branch at least 75 percent | PASS | 86.40 percent and 80.73 percent (final-toolchain-tests.2026-10-25T23-09.md) | +| 5.2 | New code at least 90 percent line coverage | PASS | coverage-comparison.2026-10-25T23-09.md (lowest 91.84 percent) | +| 5.3 | Changed lines do not lose coverage | PASS | coverage-comparison.2026-10-25T23-09.md (every row PASS) | +| 5.4 | No coverage exclusion added | PASS | coverage.config unchanged; 0 `ExcludeFromCodeCoverage` | +| 6.1 | Full suite passes | PASS | 7734 of 7734 passed | +| 6.2 | Node suites pass in five file orders | PASS | node-suites-run.2026-10-25T22-50.md | +| 7.1 | Scope fence and EXCLUDED files | PASS | scope-fences.2026-10-25T22-44.md (0 outside, 0 EXCLUDED) | +| 7.2 | Committed evidence is Markdown only; no host paths | PASS | evidence-form.2026-10-25T23-12.md | +| 8.1 | Gaps and exceptions documented | PASS | fail-before-exception.2026-10-25T22-23.md; run notes in qfc-open-fail-before, page-scripts-fail-before and page-scripts-pass-after | +| 8.2 | COM/VSTO/WinForms coverage exemption used | N/A | no new exemption; no WinForms or COM code changed | +| 9.1 | Summary of changes matches the spec's design summary | PASS | PROD-MODIFY and PROD-CREATE set equals spec Files/modules to change | +| 10.1 | Compliance verdict | PASS | no FAIL row in this walk | + +## Code review + +| # | Check | Result | Evidence | +|---|---|---|---| +| CR1 | Design resolutions R1 to R10 implemented as written | PASS | R6 to R9 in the QFC partials; R8 placement deviation recorded (row 2.4) | +| CR2 | #440 Left walk, Left segment hiding and `[+]` restore removed in both viewers | PASS | BreadcrumbStateModel.LeftArrow and RightArrow; BreadcrumbRow.LeftArrow; R3 in ToggleAsync | +| CR3 | Invariants I1 to I6 preserved, with S1 narrowing | PASS | unknown identities are no-ops; stale fetch guard kept; ReconcilePendingNode moves a hidden pending node to its nearest visible ancestor | +| CR4 | Open-list moves never change the committed value until Enter | PASS | OpenMove_DoesNotChangeCommittedValue; PendingIdentity setter clears PendingNode, so a row-level move cannot leave a stale node | +| CR5 | Escape restores row shape and value (D-Q24) | PASS | SelectorKeyParent_Open_ThenEscape_RestoresRowShapeAndValue; Cancel_AfterParentOnShortenedRow_RestoresShortenedShape | +| CR6 | Page-script parity between the C# key sets and both scripts | PASS | string-contract twins for every node test (page-scripts-pass-after.2026-10-25T22-42.md) | +| CR7 | The existing `RenderDescendantWithChildren_OffersIndependentExpansionControl` contract kept | PASS | `if (child.hasChildren)` retained; AC14 guard placed on the toggle append | +| CR8 | Defensive code paths reachable or justified | PASS | non-anchored ReExpand reached by `ArrowRight_OnRowCollapsedAwayFromActiveSegment_RestoresChain`; refused-toggle guard folded into a positive block | +| CR9 | Legacy page `unhandledArrow` reports while the list is open | N/A | after S6 the page always posts `arrowKey`; a stale page's report takes the router route (synthetic key plus the router's unhandled echo), the same as before #1007 | +| CR10 | Comments explain why, not what | PASS | #1007 references on each changed rule | + +## Feature audit (spec acceptance criteria) + +| AC | Result | Evidence | +|---|---|---| +| AC1 to AC11, AC14 | PASS | ac-reconciliation.2026-10-25T23-14.md (cited tests Passed in the final run) | +| AC12 | PASS | 8 fail-before and 8 pass-after artifacts; one exception dossier | +| AC13 | PASS | one clean toolchain pass, coverage PASS, node runs, file sizes | +| Manual Outlook verification | N/A | a human step recorded by the orchestrator; not claimed here | +| Fail-before for `UnhandledRightArrow_RaisesUnhandledArrowRight` | N/A | structurally impossible; fail-before-exception.2026-10-25T22-23.md | +| Fail-before for `ctrl+ArrowRight posts arrowKey right` | PASS | after the fixture change within P16-T2, the test failed before the fix (page-scripts-fail-before.2026-10-25T22-38.md) | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/self-review.2026-10-26T00-09.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/self-review.2026-10-26T00-09.md new file mode 100644 index 000000000..fcf7337f9 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/self-review.2026-10-26T00-09.md @@ -0,0 +1,63 @@ +# Self-Review (Issue #1007, remediation cycle 1, P5-T16) + +Timestamp: 2026-10-26T00-09 +Command: checklist walk of the policy-audit, code-review, and feature-audit sections against the diff from the merge base of origin/main and HEAD (`git diff --shortstat `: 122 files changed in the worktree), plus the uncommitted WRITE-SET changes of this cycle, including the remediation plan +EXIT_CODE: 0 +Output Summary: 40 rows (26 policy-audit, 7 code-review, 7 feature-audit): 36 PASS, 0 FAIL, 4 N/A, including one row each for B-1, N-1, and N-2. No row is FAIL, so no fix and no loop re-run followed this walk. + +Template deviation: `docs/features/templates/policy_audit/` does not exist in TaskMaster, and the MCP template resolver named by `.claude/skills/policy-audit-template-usage/SKILL.md` is not in this executor's tool set. As P5-T16 directs, and as in `self-review.2026-10-25T23-14.md`, the policy-audit walk uses the skill's canonical section list (`## 1` to `## 10`). The code-review and feature-audit walks use the review dimensions of the plan's Pre-Mortem and the spec's acceptance criteria. Rows that this cycle does not touch carry forward from `self-review.2026-10-25T23-14.md` and are re-evidenced here with this cycle's final-pass artifacts. + +## Policy audit (canonical sections) + +| # | Section / check | Result | Evidence | +|---|---|---|---| +| 1.1 | Unit tests independent, isolated, fast, deterministic | PASS | csharp-isolation.2026-10-26T00-06.md (7 runs, 0 failures); test-policy-audit.2026-10-26T00-07.md (no sleeps, delays, clock reads, or retries) | +| 1.2 | Arrange-Act-Assert structure and documented intent | PASS | test-policy-audit.2026-10-26T00-07.md: every new test has AAA sections and an XML summary citing #1007 | +| 1.3 | No temporary files and no external dependencies | PASS | 0 hits for `Path.GetTemp`, `File.Write`, and `File.Create`; providers are strict Moq mocks; the B-1 test reads the embedded resource string | +| 1.4 | Positive, negative, edge, and state-transition scenarios | PASS | B-1 has a positive control (`.sub.selected`) and the target rule; the N-1 tests assert the restored state together with the negative (no subfolder query, no leaf expansion) | +| 2.1 | Simplicity and separation of concerns | PASS | one selector added to an existing rule; no production C# change | +| 2.2 | File size at most 500 lines | PASS | file-sizes.2026-10-26T00-06.md (largest 499, unedited) | +| 2.3 | Fail fast; no silent broad catch | N/A | no production code changed | +| 2.4 | Public API compatibility | PASS | no public C# member changed; the HTML change adds one selector | +| 2.5 | Supporting documents updated | PASS | plan check-offs; AC4 in spec.md and issue.md; evidence set | +| 3.1 | CSharpier formatting | PASS | final-toolchain-format.2026-10-26T00-00.md (pass 2 clean; `Checked 1700 files`) | +| 3.2 | Analyzers with /t:Rebuild | PASS | final-toolchain-analyzers.2026-10-26T00-01.md (0 warnings, 0 errors) | +| 3.3 | Nullable: TreatWarningsAsErrors build without /p:Nullable=enable | PASS | final-toolchain-typecheck.2026-10-26T00-01.md; the new UtilitiesCS.Test partial carries `#nullable enable`; the new QuickFiler.Test partial uses C# 7.3 syntax and no directive | +| 3.4 | `` for every new C# file | PASS | one entry each in QuickFiler.Test.csproj and UtilitiesCS.Test.csproj; no other project-file diff | +| 4.1 | MSTest + Moq + FluentAssertions | PASS | test-policy-audit.2026-10-26T00-07.md | +| 4.2 | Parallel execution kept; no [DoNotParallelize], Workers=1, or retries | PASS | parallel runsettings in every run; 0 hits for `DoNotParallelize` | +| 5.1 | Repository line at least 80 percent and branch at least 75 percent | PASS | 86.49 percent and 80.85 percent (final-toolchain-tests.2026-10-26T00-03.md) | +| 5.2 | No regression; N-1 lines now executed | PASS | coverage-comparison.2026-10-26T00-04.md: every figure at or above baseline; every N1-LINES line hit | +| 5.3 | No coverage exclusion added | PASS | coverage.config unchanged; 0 `ExcludeFromCodeCoverage` added | +| 6.1 | Full suite passes | PASS | 7761 of 7761 passed (7756 + 5) | +| 6.2 | Node suites pass in normal and reversed order | PASS | node-suites-run.2026-10-26T00-05.md (90 of 90 in each order) | +| 7.1 | Scope fence | PASS | scope-fences.2026-10-26T00-06.md (WRITE-SET paths only; no C# production file; no `.claude/` path) | +| 7.2 | Committed evidence is Markdown only; no host paths | PASS | test-policy-audit.2026-10-26T00-07.md evidence-form checks | +| 8.1 | Gaps and exceptions documented | PASS | the pass-1 format rewrite and restart are recorded in final-toolchain-format.2026-10-26T00-00.md; the preflight fix is recorded in preflight-fixes.2026-10-25T23-50.md | +| 8.2 | COM/VSTO/WinForms coverage exemption used | N/A | no exemption added or relied on | +| 9.1 | Summary of changes matches the remediation scope | PASS | B-1 stylesheet rule plus test, the N-1 tests, and the N-2 renames; nothing else | +| 10.1 | Compliance verdict | PASS | no FAIL row in this walk | + +## Code review + +| # | Check | Result | Evidence | +|---|---|---|---| +| CR1 | B-1: a pending descendant (`.sub.active`) is drawn with the committed-subfolder selection colours in the light and dark themes | PASS | qfc-pending-style.2026-10-26T00-05.md; fail-before 2026-10-25T23-55 and pass-after 2026-10-25T23-55 | +| CR2 | B-1 test cannot be satisfied by the page script's `"sub active"` string | PASS | the test reads only the text between `` and matches the selector list exactly; the fail-before run proves it fails on the unfixed resource | +| CR3 | N-1: the four unchanged paths each have a direct test and are executed | PASS | n1-direct-tests.2026-10-25T23-58.md; coverage-comparison.2026-10-26T00-04.md (N1-LINES body hits 0 to 1) | +| CR4 | N-2: the six renamed tests' names agree with their assertions | PASS | test-renames.2026-10-25T23-59.md (old names 0 matches, new names 1 each) | +| CR5 | Keyboard state machine, `BreadcrumbVisibleOrder`, and C# production files untouched | PASS | scope-fences.2026-10-26T00-06.md | +| CR6 | Comments explain why, not what | PASS | each new test summary states the scenario and the reason it is reachable (D3) | +| CR7 | No acceptance-criterion text edited | PASS | ac-reconciliation.2026-10-26T00-08.md (text unchanged vs HEAD) | + +## Feature audit (spec acceptance criteria) + +| AC | Result | Evidence | +|---|---|---| +| AC4 | PASS | P2-T2 pass-after, P5-T7 rule check, P5-T4 trx rows for the B-1 test and the four AC4 tests; checked in spec.md and issue.md | +| AC1 to AC3, AC5 to AC14 | PASS | unchanged since the first review; the full suite (7761 passed) and node suites (90 passed in both orders) still pass | +| B-1 (remediation finding) | PASS | CR1, CR2 | +| N-1 (remediation finding) | PASS | CR3 | +| N-2 (remediation finding) | PASS | CR4 | +| Manual Outlook verification | N/A | a human step recorded by the orchestrator; not claimed here | +| TaskMaster CI checks on the PR | N/A | no PR exists yet; a PR-time gate (O-2) | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-25T22-47.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-25T22-47.md new file mode 100644 index 000000000..acd7ed2f2 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-25T22-47.md @@ -0,0 +1,38 @@ +# Test Policy Audit (Issue #1007, P18-T4) + +Timestamp: 2026-10-25T22-47 +Command: per-file scan by a helper under SCRATCH: MSTest, FluentAssertions and Moq usings; `[TestMethod]`/`[DataTestMethod]` count; `// Arrange`, `// Act`, `// Assert` markers; ordinal counts of `Path.GetTemp`, `File.Write`, `File.Create`, `Thread.Sleep`, `Task.Delay`, `DoNotParallelize` and `new WebView2BreadcrumbHost`; the `` entry for each new file; `#1007` references +EXIT_CODE: 0 +Output Summary: 24 rows (11 TEST-CREATE C# files, 13 edited test files); every row passes. Every file uses MSTest and FluentAssertions, and every file that builds a mock uses Moq. The seven banned tokens occur 0 times in all 24 files. Each new file has its ``. Each rewritten test's summary cites #1007 (spot-checked per Disposition Register row during P8, P10, P12, P14 and P16). The node test file is outside this C# audit; it creates no files and uses no timers. + +Audit fixes made in this task, both to test text only: +- `QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs` had no Arrange/Act/Assert markers in its 18 tests: the earlier G5 tests and the G6 and G7 tests added in this run separated the phases with blank lines only. The two blank separators of each test became `// Act` and `// Assert`, and each body gained `// Arrange`. The file is now 477 physical lines and has 54 markers. +- `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs` `SelectorKeyMessage_RoundTripsOnlySupportedKeys`, whose data rows P10-T4 extended, gained a summary citing #1007 AC8 and AC11. +The final toolchain pass (P19-T1 to P19-T4) runs after these fixes. + +| File | MSTest | FluentAssertions | Moq | Test methods | AAA markers | Banned hits | Compile Include | #1007 refs | Result | +|---|---|---|---|---|---|---|---|---|---| +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbVisibleOrderTests.cs | yes | yes | n/a (no mocks) | 13 | 33 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTreeIssue1007Tests.cs | yes | yes | n/a (no mocks) | 13 | 33 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.cs | yes | yes | yes | 14 | 42 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderIssue1007Tests.cs | yes | yes | yes | 3 | 9 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbEmptyExpanderIssue1007Tests.cs | yes | yes | n/a (no mocks) | 5 | 15 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeJsKeysIssue1007Tests.cs | yes | yes | n/a (no mocks) | 6 | 6 (Assert-only string contracts) | 0 | yes | 1 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.cs | yes | yes | yes | 15 | 45 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.Navigation.cs | yes | yes | n/a (partial; mocks in the main part) | 15 | 43 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs | yes | yes | yes | 5 | 15 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs | yes | yes | yes | 18 | 54 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs | yes | yes | n/a (no mocks) | 8 | 13 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | yes | yes | yes | 11 | 35 | 0 | n/a | 6 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | yes | yes | yes | 13 | 39 | 0 | n/a | 3 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | yes | yes | yes | 12 | 36 | 0 | n/a | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | yes | yes | n/a (no mocks) | 21 | 50 | 0 | n/a | 3 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | yes | yes | n/a (no mocks) | 23 | 63 | 0 | n/a | 2 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | yes | yes | n/a (no mocks) | 14 | 36 | 0 | n/a | 5 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | yes | yes | n/a (no mocks) | 9 | 25 | 0 | n/a | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | yes | yes | yes | 14 | 43 | 0 | n/a | 4 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | yes | yes | yes | 12 | 33 | 0 | n/a | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs | yes | yes | n/a (no mocks) | 17 | 40 | 0 | n/a | 6 | PASS | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | yes | yes | yes | 16 | 44 | 0 | n/a | 4 | PASS | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | yes | yes | yes | 14 | 40 | 0 | n/a | 1 | PASS | +| QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs | yes | yes | yes | 15 | 18 | 0 | n/a | 3 | PASS | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-25T23-12.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-25T23-12.md new file mode 100644 index 000000000..b586107e8 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-25T23-12.md @@ -0,0 +1,33 @@ +# Test Policy Audit, Refreshed after the Coverage Remediation (Issue #1007, P18-T4) + +Timestamp: 2026-10-25T23-12 +Command: per-file scan by a helper under SCRATCH: MSTest, FluentAssertions and Moq usings; `[TestMethod]`/`[DataTestMethod]` count; `// Arrange`, `// Act`, `// Assert` markers; ordinal counts of `Path.GetTemp`, `File.Write`, `File.Create`, `Thread.Sleep`, `Task.Delay`, `DoNotParallelize` and `new WebView2BreadcrumbHost`; the `` entry for each new file; `#1007` references +EXIT_CODE: 0 +Output Summary: 24 rows (11 TEST-CREATE C# files, 13 edited test files); every row passes. This supersedes test-policy-audit.2026-10-25T22-47.md. Since then, the P19 coverage remediation added 6 tests to `BreadcrumbSelectionSessionTreeIssue1007Tests.cs`, 3 to `FolderBreadcrumbBridgeRouterIssue1007Tests.cs` and 2 to `BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs`, and 2 guard assertions to `BreadcrumbSelectorMessagesTests.cs`. Each new test has a summary citing #1007 (AC, decision or R reference), Arrange/Act/Assert markers, and no banned token. The seven banned tokens occur 0 times in all 24 files. + +| File | MSTest | FluentAssertions | Moq | Test methods | AAA markers | Banned hits | Compile Include | #1007 refs | Result | +|---|---|---|---|---|---|---|---|---|---| +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbVisibleOrderTests.cs | yes | yes | n/a (no mocks) | 13 | 33 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTreeIssue1007Tests.cs | yes | yes | n/a (no mocks) | 19 | 49 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.cs | yes | yes | yes | 17 | 51 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderIssue1007Tests.cs | yes | yes | yes | 3 | 9 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbEmptyExpanderIssue1007Tests.cs | yes | yes | n/a (no mocks) | 5 | 15 | 0 | yes | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeJsKeysIssue1007Tests.cs | yes | yes | n/a (no mocks) | 6 | 6 (Assert-only string contracts) | 0 | yes | 1 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.cs | yes | yes | yes | 15 | 45 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.Navigation.cs | yes | yes | n/a (partial; mocks in the main part) | 15 | 43 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs | yes | yes | yes | 7 | 21 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs | yes | yes | yes | 18 | 54 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs | yes | yes | n/a (no mocks) | 8 | 13 | 0 | yes | 1 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | yes | yes | yes | 11 | 35 | 0 | n/a | 6 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.Selection.cs | yes | yes | yes | 13 | 39 | 0 | n/a | 3 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.Part2.cs | yes | yes | yes | 12 | 36 | 0 | n/a | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs | yes | yes | n/a (no mocks) | 21 | 50 | 0 | n/a | 3 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelTests.cs | yes | yes | n/a (no mocks) | 23 | 63 | 0 | n/a | 2 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | yes | yes | n/a (no mocks) | 14 | 36 | 0 | n/a | 5 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectionSessionTests.cs | yes | yes | n/a (no mocks) | 9 | 25 | 0 | n/a | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterTests.cs | yes | yes | yes | 14 | 43 | 0 | n/a | 4 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterInFlightTests.cs | yes | yes | yes | 12 | 33 | 0 | n/a | 1 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbSelectorMessagesTests.cs | yes | yes | n/a (no mocks) | 17 | 40 | 0 | n/a | 6 | PASS | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs | yes | yes | yes | 16 | 44 | 0 | n/a | 4 | PASS | +| QuickFiler.Test/Viewers/BreadcrumbSelectorCoordinatorTests.cs | yes | yes | yes | 14 | 40 | 0 | n/a | 1 | PASS | +| QuickFiler.Test/Viewers/FolderBreadcrumbAssetContractTests.cs | yes | yes | yes | 15 | 18 | 0 | n/a | 3 | PASS | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-26T00-07.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-26T00-07.md new file mode 100644 index 000000000..5177699e5 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/test-policy-audit.2026-10-26T00-07.md @@ -0,0 +1,30 @@ +# Test Policy Audit (Issue #1007, remediation cycle 1, P5-T12) + +Timestamp: 2026-10-26T00-07 +Command: regex searches over the seven edited or new test files for the using directives and the banned tokens; `git status --porcelain --untracked-files=all` for new paths; a regex search of `/evidence/` for drive-rooted paths and `Users` profile segments +EXIT_CODE: 0 +Output Summary: PASS. All 7 rows and both evidence-form checks pass. The 7 files contain 0 banned tokens, and `/evidence/` contains 0 drive-rooted paths and 0 `Users` profile segments. + +Banned-token set searched: `Path.GetTemp`, `File.Write`, `File.Create`, `Thread.Sleep`, `Task.Delay`, `DoNotParallelize`, `ExcludeFromCodeCoverage`. + +## Per-file rows + +| File | MSTest + FluentAssertions usings | Moq where a mock is used | Arrange-Act-Assert in new/changed tests | XML summary citing #1007 on each new test | Banned tokens | Result | +|---|---|---|---|---|---|---| +| QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs | yes | no mock used | yes (`Stylesheet_StylesPendingDescendantLikeSelectedSubfolder`) | yes (#1007 AC4, B-1) | 0 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs | yes | yes (`Moq`, `Times`) | yes (`LeafExpandToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChain`) | yes (#1007 R3, N-1) | 0 | PASS | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | yes | yes | renames only (bodies unchanged) | n/a (no new test) | 0 | PASS | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs | yes | yes (strict provider mock in `Harness`) | modifier edit only | n/a (no new test) | 0 | PASS | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.Unhandled.cs | yes | the mock is built by the reused `Harness` | yes (`OpenUnhandledArrowLeft_OnExpandedPendingRow_RaisesUnhandledArrowFromRouterOutput`) | yes (#1007 R8, N-1) | 0 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | yes | no mock used | yes (`RightArrow_OnRowCollapsedAwayFromActiveSegment_RestoresChain`); other changes are renames | yes (#1007 R3, N-1) | 0 | PASS | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.Unanchored.cs | yes | yes (`provider.Verify`, `Times.Never()`) | yes (`AffordanceToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChainWithoutQuery`) | yes (#1007 R3, N-1) | 0 | PASS | + +None of the new tests creates a file, sleeps, delays, reads the clock, or retries. No class carries `[DoNotParallelize]`. The isolation runs in P5-T9 and the full parallel run in P5-T4 passed. + +## Evidence-form checks + +| Check | Result | +|---|---| +| New paths ending in `.trx`, `.coverage`, or `.xml` (porcelain) | none. The raw trx and Cobertura copies stay under SCRATCH. `artifacts/csharp/coverage.xml` and `coverage/` are git-ignored and untracked. | +| Every new file under `/evidence/` is Markdown | yes (every new evidence path ends in `.md`) | +| Drive-rooted path or `Users` profile segment under `/evidence/` | 0 matches | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/thread-affinity.2026-10-25T22-44.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/thread-affinity.2026-10-25T22-44.md new file mode 100644 index 000000000..1e4c9326b --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/qa-gates/thread-affinity.2026-10-25T22-44.md @@ -0,0 +1,12 @@ +# Thread Affinity (Issue #1007, P18-T2) + +Timestamp: 2026-10-25T22-44 +Command: ripgrep count of `ConfigureAwait\(false\)` and of `lock \(` over `QuickFiler/Controllers/BreadcrumbBridgeRouter*.cs`; positive control: the same `ConfigureAwait\(false\)` count over `UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs` +EXIT_CODE: 0 +Output Summary: the EFC router files (5: `BreadcrumbBridgeRouter.cs`, `.Arrows.cs`, `.Descendants.cs`, `.Navigation.cs`, `.Selection.cs`) contain 0 `ConfigureAwait(false)` and 0 `lock (` occurrences, so #1002 invariant I6 holds. The positive control finds 6 `ConfigureAwait(false)` occurrences in the QFC router partial, which shows the search pattern matches when the token is present. Every check holds. + +| Check | Scope | Count | Expected | Result | +|---|---|---|---|---| +| ConfigureAwait(false) | QuickFiler/Controllers/BreadcrumbBridgeRouter*.cs (5 files) | 0 | 0 | PASS | +| lock ( | QuickFiler/Controllers/BreadcrumbBridgeRouter*.cs (5 files) | 0 | 0 | PASS | +| ConfigureAwait(false) (positive control) | UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs | 6 | at least 1 | PASS | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/ac14-fail-before.2026-10-23T13-51.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/ac14-fail-before.2026-10-23T13-51.md new file mode 100644 index 000000000..58b8d073f --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/ac14-fail-before.2026-10-23T13-51.md @@ -0,0 +1,18 @@ +# AC14 Fail-Before (Issue #1007, P2-T3) + +Timestamp: 2026-10-23T13-51 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~OutlookFolderHierarchyProviderIssue1007Tests|FullyQualifiedName~BreadcrumbEmptyExpanderIssue1007Tests" (results directory and trx under SCRATCH) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. Tests: total 8, executed 8, passed 2, failed 6. Every G1 non-guard test failed on its assertion; both guards passed. Summary derived from the trx; the trx is not committed. + +| Outcome | Test | Failure message (from the trx) | +|---|---|---| +| Failed | MapNode_UnresolvableChildKey_HasChildrenFalse | Expected chain.Last().HasChildren to be False because Acme's only child key does not resolve in the snapshot, but found True. | +| Failed | GetImmediateSubfolders_ChildWithOnlyUnresolvableKeys_HasChildrenFalse | Expected children[0].HasChildren to be False because the subfolder fetch for Acme would return zero children, but found True. | +| Passed (guard) | GetAncestorChain_ResolvableChildKey_HasChildrenTrue | - | +| Failed | EfcRenderer_ZeroChildrenFetch_RendersNoAffordance | Did not expect html (row fragment) to contain `data-role="leaf"` because the fetch attached zero children. | +| Failed | EfcRenderer_ZeroChildrenDescendantFetch_RendersNoNodeAffordance | Did not expect html (row fragment) to contain `data-role="node"` because the node's fetch returned no child. | +| Failed | QfcProjection_ZeroChildrenFetch_EmitsNoPlusOrMinus | Expected row.Cells kinds {Segment, Arrow, Segment, Minus} to not contain BreadcrumbCellKind.Minus. | +| Failed | QfcProjection_ZeroChildrenDescendantFetch_ReportsNoChildren | Expected projected.HasChildren to be False because the node's fetch returned no child, but found True. | +| Passed (guard) | EfcRenderer_ChildrenFetch_KeepsMinusAffordance | - | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/ac14-pass-after.2026-10-23T13-54.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/ac14-pass-after.2026-10-23T13-54.md new file mode 100644 index 000000000..6f6ffec07 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/ac14-pass-after.2026-10-23T13-54.md @@ -0,0 +1,19 @@ +# AC14 Pass-After (Issue #1007, P3-T4) + +Timestamp: 2026-10-23T13-54 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~Breadcrumb|FullyQualifiedName~OutlookFolderHierarchyProvider" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. BREADCRUMB-FILTER run: total 957, executed 957, passed 957, failed 0 (BASELINE-FAILURES is empty). Every G1 test passed. + +Note: the first pass-after run reported 6 failures in `BreadcrumbSelectorMessagesTests` (`SelectorKeyMessage_RoundTripsOnlySupportedKeys`, `ViewMessage_RoundTripsModeOpenAndStableIdentities`), which construct the messages through `Activator.CreateInstance` with the original argument lists; reflection does not supply optional parameters. The Phase 1 optional parameters on `BreadcrumbSelectorKeyMessage` and `BreadcrumbSelectorViewMessage` were replaced by chained constructor overloads (no test change), and the run above is the re-run. + +| Outcome | Class | Test | +|---|---|---| +| Passed | OutlookFolderHierarchyProviderIssue1007Tests | MapNode_UnresolvableChildKey_HasChildrenFalse | +| Passed | OutlookFolderHierarchyProviderIssue1007Tests | GetImmediateSubfolders_ChildWithOnlyUnresolvableKeys_HasChildrenFalse | +| Passed | OutlookFolderHierarchyProviderIssue1007Tests | GetAncestorChain_ResolvableChildKey_HasChildrenTrue | +| Passed | BreadcrumbEmptyExpanderIssue1007Tests | EfcRenderer_ZeroChildrenFetch_RendersNoAffordance | +| Passed | BreadcrumbEmptyExpanderIssue1007Tests | EfcRenderer_ZeroChildrenDescendantFetch_RendersNoNodeAffordance | +| Passed | BreadcrumbEmptyExpanderIssue1007Tests | QfcProjection_ZeroChildrenFetch_EmitsNoPlusOrMinus | +| Passed | BreadcrumbEmptyExpanderIssue1007Tests | QfcProjection_ZeroChildrenDescendantFetch_ReportsNoChildren | +| Passed | BreadcrumbEmptyExpanderIssue1007Tests | EfcRenderer_ChildrenFetch_KeepsMinusAffordance | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-25T22-48.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-25T22-48.md new file mode 100644 index 000000000..20df7f1a1 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-25T22-48.md @@ -0,0 +1,19 @@ +# C# Isolation Runs (Issue #1007, P18-T5) + +Timestamp: 2026-10-25T22-48 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then for each new class in reverse register order a fresh vstest.console.exe /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~" process, then one vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll process over all nine classes (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. Ten runs, all under the parallel runsettings (`Workers` 0, `ClassLevel`) with no `[DoNotParallelize]`, no worker override and no retry: nine single-class processes (115 tests in total) and one combined process (115 tests). Every run had zero failures and vstest exit 0. Summaries derived from each run's trx. + +| Run | Class (register group) | Assembly | Total | Passed | Failed | vstest exit | +|---|---|---|---|---|---|---| +| 1 | BreadcrumbBridgeJsKeysIssue1007Tests (G8) | UtilitiesCS.Test | 6 | 6 | 0 | 0 | +| 2 | FolderBreadcrumbKeysIssue1007Tests (G8) | QuickFiler.Test | 8 | 8 | 0 | 0 | +| 3 | BreadcrumbBridgeCoordinatorIssue1007Tests (G5, G6, G7) | QuickFiler.Test | 18 | 18 | 0 | 0 | +| 4 | FolderBreadcrumbBridgeRouterIssue1007Tests (G5, G7) | UtilitiesCS.Test | 14 | 14 | 0 | 0 | +| 5 | BreadcrumbSelectionSessionTreeIssue1007Tests (G5) | UtilitiesCS.Test | 13 | 13 | 0 | 0 | +| 6 | BreadcrumbBridgeRouterIssue1007Tests with its Navigation and DoubleClick partials (G3, G4) | QuickFiler.Test | 35 | 35 | 0 | 0 | +| 7 | BreadcrumbVisibleOrderTests (G2) | UtilitiesCS.Test | 13 | 13 | 0 | 0 | +| 8 | BreadcrumbEmptyExpanderIssue1007Tests (G1) | UtilitiesCS.Test | 5 | 5 | 0 | 0 | +| 9 | OutlookFolderHierarchyProviderIssue1007Tests (G1) | UtilitiesCS.Test | 3 | 3 | 0 | 0 | +| all | the nine classes in one process | UtilitiesCS.Test, QuickFiler.Test | 115 | 115 | 0 | 0 | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-25T23-12.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-25T23-12.md new file mode 100644 index 000000000..d4dc49f0c --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-25T23-12.md @@ -0,0 +1,19 @@ +# C# Isolation Runs, Refreshed after the Coverage Remediation (Issue #1007, P18-T5) + +Timestamp: 2026-10-25T23-12 +Command: for each new class in reverse register order a fresh vstest.console.exe /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~" process, then one vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll process over all nine classes (assemblies from the 23-07 clean-pass build; results under SCRATCH) +EXIT_CODE: 0 +Output Summary: ten runs, all under the parallel runsettings (`Workers` 0, `ClassLevel`) with no `[DoNotParallelize]`, no worker override and no retry: nine single-class processes (126 tests in total) and one combined process (126 tests). Every run had zero failures and vstest exit 0. This supersedes csharp-isolation.2026-10-25T22-48.md, because the P19 coverage remediation added 11 tests to three of the nine classes. Summaries derived from each run's trx. + +| Run | Class (register group) | Assembly | Total | Passed | Failed | vstest exit | +|---|---|---|---|---|---|---| +| 1 | BreadcrumbBridgeJsKeysIssue1007Tests (G8) | UtilitiesCS.Test | 6 | 6 | 0 | 0 | +| 2 | FolderBreadcrumbKeysIssue1007Tests (G8) | QuickFiler.Test | 8 | 8 | 0 | 0 | +| 3 | BreadcrumbBridgeCoordinatorIssue1007Tests (G5, G6, G7) | QuickFiler.Test | 18 | 18 | 0 | 0 | +| 4 | FolderBreadcrumbBridgeRouterIssue1007Tests (G5, G7) | UtilitiesCS.Test | 17 | 17 | 0 | 0 | +| 5 | BreadcrumbSelectionSessionTreeIssue1007Tests (G5) | UtilitiesCS.Test | 19 | 19 | 0 | 0 | +| 6 | BreadcrumbBridgeRouterIssue1007Tests with its Navigation and DoubleClick partials (G3, G4) | QuickFiler.Test | 37 | 37 | 0 | 0 | +| 7 | BreadcrumbVisibleOrderTests (G2) | UtilitiesCS.Test | 13 | 13 | 0 | 0 | +| 8 | BreadcrumbEmptyExpanderIssue1007Tests (G1) | UtilitiesCS.Test | 5 | 5 | 0 | 0 | +| 9 | OutlookFolderHierarchyProviderIssue1007Tests (G1) | UtilitiesCS.Test | 3 | 3 | 0 | 0 | +| all | the nine classes in one process | UtilitiesCS.Test, QuickFiler.Test | 126 | 126 | 0 | 0 | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-26T00-06.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-26T00-06.md new file mode 100644 index 000000000..c36ec88c8 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/csharp-isolation.2026-10-26T00-06.md @@ -0,0 +1,20 @@ +# C# Isolation Runs (Issue #1007, remediation cycle 1, P5-T9) + +Timestamp: 2026-10-26T00-06 +Command: for each class, a fresh vstest.console.exe QuickFiler.Test.dll UtilitiesCS.Test.dll /InIsolation /Settings:scripts/vscode/TaskMaster.cli.runsettings "/TestCaseFilter:FullyQualifiedName~.." process, then one process with the six filters joined by `|`; against the P5-T3/P5-T4 build; results under SCRATCH +EXIT_CODE: 0 +Output Summary: Seven runs: six single-class processes and one combined process. Every run matched at least one test and had 0 failures. The combined run's 146 tests equal the sum of the six single-class runs. The runsettings are parallel (Workers 0, ClassLevel), with no worker override and no retry. Counts are derived from each trx. + +The `..` filter form, with dots on both sides, keeps `BreadcrumbBridgeRouterIssue1007Tests` and `BreadcrumbBridgeRouterTests` from also matching the `FolderBreadcrumb...` classes. + +| Run | Class order | Total | Passed | Failed | +|---|---|---|---|---| +| 1 | FolderBreadcrumbKeysIssue1007Tests | 9 | 9 | 0 | +| 2 | BreadcrumbBridgeRouterIssue1007Tests | 38 | 38 | 0 | +| 3 | BreadcrumbBridgeRouterTests | 24 | 24 | 0 | +| 4 | FolderBreadcrumbBridgeRouterIssue1007Tests | 18 | 18 | 0 | +| 5 | BreadcrumbStateModelTests | 38 | 38 | 0 | +| 6 | BreadcrumbBridgeCoordinatorIssue1007Tests | 19 | 19 | 0 | +| all | the six classes above, one process | 146 | 146 | 0 | + +The full suite also ran in parallel in P5-T4 (7761 passed). diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-horizontal-fail-before.2026-10-23T14-20.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-horizontal-fail-before.2026-10-23T14-20.md new file mode 100644 index 000000000..f394e67af --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-horizontal-fail-before.2026-10-23T14-20.md @@ -0,0 +1,44 @@ +# EFC Horizontal-Key and Double-Click Fail-Before (Issue #1007, P8-T7) + +Timestamp: 2026-10-23T14-20 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~BreadcrumbBridgeRouterIssue1007Tests|FullyQualifiedName~QuickFiler.Test.Controllers.BreadcrumbBridgeRouterTests|FullyQualifiedName~BreadcrumbRowStateTests|FullyQualifiedName~BreadcrumbBridgeRouterQueueTests" (results under SCRATCH) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: build `0 Error(s)`. Tests: total 106, executed 106, passed 80, failed 26. Every G4 non-guard test, `LeftArrow_NeverHidesTrailingSegment` and both rewritten `.Selection.cs` tests failed on their assertions; the four guards passed. Summary derived from the trx. + +Run note: the first attempt of this run did not finish within 10 minutes. `ArrowRight_ReentrantDuringFetch_EndsExpandedWithOneQuery` awaited the second Right before releasing the held fetch, and on the pre-fix code that second Right also awaits the held fetch, so the test could not complete. The test host tree of that run (started from this worktree) was stopped, both held-fetch tests were changed to release the fetch before awaiting any key task, and the run above is the re-run. The scoped launcher now stops a run that exceeds 300 seconds. + +| Outcome | Test | Failure message (from the trx, shortened) | +|---|---|---| +| Failed | ArrowRight_OnExpandedRow_SelectsFirstDescendantWithNodeId | Expected "Clients\North\Q3", but "Clients\North". | +| Failed | ArrowRight_OnUnexpandedNodeWithChildren_ExpandsNode | MockException: expected the Q3 subfolder query once, but was 0 times. | +| Failed | ArrowRight_OnExpandedNode_SelectsFirstChildNode | Expected "Clients\North\Q3\Invoices", but "Clients\North\Q3". | +| Failed | ArrowRight_ReentrantDuringFetch_EndsExpandedWithOneQuery | MockException: expected the North subfolder query once, but was 2 times. | +| Failed | ArrowLeft_OnExpandedRow_CollapsesAndKeepsSelection | Expected the latest render to contain ">Q3" after Right (the #440 walk had moved the active segment). | +| Failed | ArrowLeft_OnExpandedNode_CollapsesOnlyThatNode | Expected the latest render to contain ">Q4" (the #440 walk collapsed the row). | +| Failed | ArrowLeft_OnNonExpandedRow_IsNoOp | Expected Posts.Count to be 1, but found 2. | +| Failed | ArrowLeft_OnCollapsedNode_IsNoOp | Expected Posts.Count to be 3, but found 4. | +| Failed | CollapseHidingSelection_RecommitsNearestVisibleAncestor | Expected "Clients\North\Q3", but "Clients\North\Q3\Invoices". | +| Failed | ParentLeft_OnRow_ActivatesParentSegmentCommitsAndShortens | Expected "Clients", but "Clients\North". | +| Failed | ParentLeft_OnNode_SelectsParentKeepingExpansion | Expected "Clients\North\Q3", but "Clients\North\Q3\Invoices". | +| Passed (guard) | ArrowRight_OnUnexpandedRowWithChildren_Expands | - | +| Passed (guard) | ArrowRight_OnExpandedNodeWithFetchInFlight_DoesNothing | - | +| Passed (guard) | ArrowRight_OnLeaf_PostsNothing | - | +| Passed (guard) | ParentLeft_AtFirstSegment_IsNoOp | - | +| Failed | SegmentDoubleClick_ActivatesCommitsAndShortensAtomically | Expected "Clients\Acme", but "Clients\Acme\Beta". | +| Failed | LeafExpandToggle_OnShortenedRow_ListsSegmentChildren | MockException: expected the Acme subfolder query once, but was 0 times. | +| Failed | ArrowRight_OnShortenedRow_ListsSegmentChildrenWithSingleQuery | MockException: expected the Acme subfolder query once, but was 0 times. | +| Failed | ShortenedRowExpanded_NextRight_SelectsFirstChild | Expected "Clients\Acme", but found null. | +| Failed | ShortenedRowExpanded_RendersCollapseAffordanceAtTerminal | Expected the latest render to contain the reexpand collapse affordance (minus). | +| Failed | LeftArrow_NeverHidesTrailingSegment (P8-T5) | Expected row.LeftArrow() to be False, but found True. | +| Failed | LeafExpandToggle_OnCollapsedRow_ReExpandsWithoutProviderQuery (P8-T4) | MockException: expected the Inbox subfolder query once, but was 0 times. | +| Failed | ArrowKeyRight_WhenCollapsed_ReExpandsWithoutProviderQuery (P8-T4) | MockException: expected the Inbox subfolder query once, but was 0 times. | +| Failed | HandleArrowKey_LeftOnMultiSegmentRow_ActivatesParentSegment (P8-T3) | Expected _posted.Count to be 0 because Left on a non-expanded row is a no-op, but found 1. | +| Failed | HandleArrowKey_RepeatedLeft_WalksToRootThenFallsThroughToExistingBehavior (P8-T3) | Expected no row-1 render, but found row-scoped renders. | +| Failed | HandleArrowKey_RightOnActivatedParent_ExpandsViaSingleImmediateSubfolderCall (P8-T3) | MockException: expected the Inbox\Projects query once, but was 0 times. | +| Failed | HandleArrowKey_RightAfterExpansion_DescendsByChildActivation (P8-T3) | Expected the last post to contain aria-selected (no node was highlighted). | +| Failed | Boundary_EfcLeftAtRootAndRightOnChildlessNode_RemainSilentNoOps (P8-T3) | Expected _posted.Count to be 0, but found 4. | +| Passed (P8-T3) | SegmentDoubleClick_OnNonLeafSegment_CollapsesAndReRenders | - | +| Failed | RightArrow_WhenCollapsed_RestoresFullBreadcrumb (P8-T5) | Expected anchored.IsLeafExpanded to be True, but found False. | +| Failed | ToggleLeafExpanded_WhileCollapsed_IsNoOp (P8-T5) | Expected anchored.ToggleLeafExpanded() to be True, but found False. | +| Passed (P8-T6) | SegmentDoubleClick_ValidIndex_ViaHostEvent_CollapsesRowAndPostsRender | - | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-horizontal-pass-after.2026-10-23T14-24.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-horizontal-pass-after.2026-10-23T14-24.md new file mode 100644 index 000000000..4dad59819 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-horizontal-pass-after.2026-10-23T14-24.md @@ -0,0 +1,43 @@ +# EFC Horizontal-Key and Double-Click Pass-After (Issue #1007, P9-T9) + +Timestamp: 2026-10-23T14-24 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~Breadcrumb|FullyQualifiedName~OutlookFolderHierarchyProvider" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. BREADCRUMB-FILTER run: total 1004, executed 1004, passed 1004, failed 0. Every G4 test (20) and every Phase 8 edited test passed; the unchanged `BreadcrumbRowDescendantsIssue1002Tests`, `BreadcrumbHtmlRendererTests` and `DescendantToggle_DoesNotChangeSelection` passed in the same run. 34 outcome rows below (20 G4, 11 Phase 8 edited tests, 3 unchanged spot checks). + +| Outcome | Class | Test | +|---|---|---| +| Passed | BreadcrumbBridgeRouterTests | ArrowKeyRight_WhenCollapsed_ReExpandsWithoutProviderQuery | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowLeft_OnCollapsedNode_IsNoOp | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowLeft_OnExpandedNode_CollapsesOnlyThatNode | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowLeft_OnExpandedRow_CollapsesAndKeepsSelection | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowLeft_OnNonExpandedRow_IsNoOp | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnExpandedNode_SelectsFirstChildNode | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnExpandedNodeWithFetchInFlight_DoesNothing | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnExpandedRow_SelectsFirstDescendantWithNodeId | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnLeaf_PostsNothing | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnShortenedRow_ListsSegmentChildrenWithSingleQuery | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnUnexpandedNodeWithChildren_ExpandsNode | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnUnexpandedRowWithChildren_Expands | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ArrowRight_ReentrantDuringFetch_EndsExpandedWithOneQuery | +| Passed | BreadcrumbBridgeRouterTests | Boundary_EfcLeftAtRootAndRightOnChildlessNode_RemainSilentNoOps | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | CollapseHidingSelection_RecommitsNearestVisibleAncestor | +| Passed | BreadcrumbBridgeRouterIssue1002Tests | DescendantToggle_DoesNotChangeSelection | +| Passed | BreadcrumbBridgeRouterTests | HandleArrowKey_LeftOnMultiSegmentRow_ActivatesParentSegment | +| Passed | BreadcrumbBridgeRouterTests | HandleArrowKey_RepeatedLeft_WalksToRootThenFallsThroughToExistingBehavior | +| Passed | BreadcrumbBridgeRouterTests | HandleArrowKey_RightAfterExpansion_DescendsByChildActivation | +| Passed | BreadcrumbBridgeRouterTests | HandleArrowKey_RightOnActivatedParent_ExpandsViaSingleImmediateSubfolderCall | +| Passed | BreadcrumbBridgeRouterTests | LeafExpandToggle_OnCollapsedRow_ReExpandsWithoutProviderQuery | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | LeafExpandToggle_OnShortenedRow_ListsSegmentChildren | +| Passed | BreadcrumbRowStateTests | LeftArrow_NeverHidesTrailingSegment | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ParentLeft_AtFirstSegment_IsNoOp | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ParentLeft_OnNode_SelectsParentKeepingExpansion | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ParentLeft_OnRow_ActivatesParentSegmentCommitsAndShortens | +| Passed | BreadcrumbRowStateTests | RightArrow_WhenCollapsed_RestoresFullBreadcrumb | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | SegmentDoubleClick_ActivatesCommitsAndShortensAtomically | +| Passed | BreadcrumbBridgeRouterQueueTests | SegmentDoubleClick_BannerRow_ViaHostEvent_ShortCircuitsBeforeRangeCheck | +| Passed | BreadcrumbBridgeRouterTests | SegmentDoubleClick_OnNonLeafSegment_CollapsesAndReRenders | +| Passed | BreadcrumbBridgeRouterQueueTests | SegmentDoubleClick_ValidIndex_ViaHostEvent_CollapsesRowAndPostsRender | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ShortenedRowExpanded_NextRight_SelectsFirstChild | +| Passed | BreadcrumbBridgeRouterIssue1007Tests | ShortenedRowExpanded_RendersCollapseAffordanceAtTerminal | +| Passed | BreadcrumbRowStateTests | ToggleLeafExpanded_WhileCollapsed_IsNoOp | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-vertical-fail-before.2026-10-23T14-00.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-vertical-fail-before.2026-10-23T14-00.md new file mode 100644 index 000000000..908ecfc56 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-vertical-fail-before.2026-10-23T14-00.md @@ -0,0 +1,28 @@ +# EFC Vertical-Key Fail-Before (Issue #1007, P6-T3) + +Timestamp: 2026-10-23T14-00 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~BreadcrumbBridgeRouterIssue1007Tests|FullyQualifiedName~QuickFiler.Test.Controllers.BreadcrumbBridgeRouterTests" (results under SCRATCH) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: build `0 Error(s)`. Tests: total 39, executed 39, passed 27, failed 12. Every G3 non-guard test failed on its assertion; the three guards and `ArrowKey_UnknownKey_IsLoggedNoOp` passed. Summary derived from the trx. + +Deviation recorded: `QuickFiler.Test` compiles at C# 7.3 (no `LangVersion` in its project file), so the new QuickFiler.Test files cannot carry `#nullable enable`; the first build failed with CS8370 and the directive and `?` annotations were removed from those files. UtilitiesCS.Test files keep the directive. + +| Outcome | Test | Failure message (from the trx) | +|---|---|---| +| Failed | ArrowDown_FromExpandedRow_SelectsFirstVisibleDescendant | SelectedFolderPath actual "Clients\South", expected "Clients\North\Q3". | +| Failed | ArrowDown_FromSelectedDescendant_SelectsNextVisibleEntry | SelectedFolderPath actual "Clients\South", expected "Clients\North\Q4". | +| Failed | ArrowDown_SkipsChildrenOfCollapsedNode | SelectedFolderPath actual "Clients\South", expected "Clients\North\Q4". | +| Failed | ArrowUp_FromRowBelowExpandedBlock_SelectsLastVisibleDescendant | Expected "Clients\North\Q4", but "Clients\North". | +| Failed | ArrowDown_OntoDescendant_CommitsArchiveRelativePathAndRaisesChanged | Expected SelectionEvents {"Clients\North\Q3"}, but {"Clients\South"}. | +| Failed | ArrowDown_OntoDescendant_ThenRowConfirmed_RaisesConfirmationForDescendant | Expected Confirmations to be 1, but found 0. | +| Failed | Home_SelectsFirstEntryWithoutFocusSearch | SelectedFolderPath actual "Clients\South", expected "Clients\North". | +| Failed | End_SelectsLastVisibleDescendant | SelectedFolderPath actual "Clients\North", expected "Clients\South\S1". | +| Failed | PageDown_MovesByPageSizeAndClamps | Expected "Clients\North\Q4", but "Clients\North". | +| Failed | PageUp_MovesByPageSizeAndClamps | SelectedFolderPath actual "Clients\South", expected "Clients\North\Q3". | +| Failed | PageDown_NonIntegerPageSize_IsRejectedByCodec | Expected a BreadcrumbMessageException to be thrown, but no exception was thrown. | +| Failed | NoSelection_DownAndHomeSelectFirst_UpAndEndSelectLast | Expected "Clients\North" because Down with no selection, but found null. | +| Passed (guard) | ArrowDown_OnLastVisibleEntry_PostsNothing | - | +| Passed (guard) | ArrowUp_OnFirstEntry_RaisesFocusSearchRequested | - | +| Passed (guard) | PageKey_InvalidPageSize_IsLoggedNoOp | - | +| Passed (edited, P6-T2) | ArrowKey_UnknownKey_IsLoggedNoOp | - | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-vertical-pass-after.2026-10-23T14-03.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-vertical-pass-after.2026-10-23T14-03.md new file mode 100644 index 000000000..cce0c4879 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/efc-vertical-pass-after.2026-10-23T14-03.md @@ -0,0 +1,24 @@ +# EFC Vertical-Key Pass-After (Issue #1007, P7-T5) + +Timestamp: 2026-10-23T14-03 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~Breadcrumb|FullyQualifiedName~OutlookFolderHierarchyProvider" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. BREADCRUMB-FILTER run: total 985, executed 985, passed 985, failed 0. Every G3 test passed; `ArrowKey_UnknownKey_IsLoggedNoOp` and the unchanged `BreadcrumbMessageCodecTests` and `BreadcrumbMessageCodecIssue1002Tests` classes passed in the same run. `QuickFiler/Controllers/BreadcrumbBridgeRouter.cs` is 477 physical lines (at or below 500). + +| Outcome | Test | +|---|---| +| Passed | ArrowDown_FromExpandedRow_SelectsFirstVisibleDescendant | +| Passed | ArrowDown_FromSelectedDescendant_SelectsNextVisibleEntry | +| Passed | ArrowDown_SkipsChildrenOfCollapsedNode | +| Passed | ArrowUp_FromRowBelowExpandedBlock_SelectsLastVisibleDescendant | +| Passed | ArrowDown_OntoDescendant_CommitsArchiveRelativePathAndRaisesChanged | +| Passed | ArrowDown_OntoDescendant_ThenRowConfirmed_RaisesConfirmationForDescendant | +| Passed | Home_SelectsFirstEntryWithoutFocusSearch | +| Passed | End_SelectsLastVisibleDescendant | +| Passed | PageDown_MovesByPageSizeAndClamps | +| Passed | PageUp_MovesByPageSizeAndClamps | +| Passed | PageDown_NonIntegerPageSize_IsRejectedByCodec | +| Passed | NoSelection_DownAndHomeSelectFirst_UpAndEndSelectLast | +| Passed | ArrowDown_OnLastVisibleEntry_PostsNothing | +| Passed | ArrowUp_OnFirstEntry_RaisesFocusSearchRequested | +| Passed | PageKey_InvalidPageSize_IsLoggedNoOp | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/fail-before-exception.2026-10-25T22-23.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/fail-before-exception.2026-10-25T22-23.md new file mode 100644 index 000000000..2a2875412 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/fail-before-exception.2026-10-25T22-23.md @@ -0,0 +1,27 @@ +# Fail-Before Exception: UnhandledRightArrow_RaisesUnhandledArrowRight (Issue #1007, P12-T3) + +Timestamp: 2026-10-25T22-23 +Command: vstest.console.exe QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~BreadcrumbBridgeCoordinatorIssue1007Tests|FullyQualifiedName~QuickFiler.Test.Viewers.BreadcrumbBridgeCoordinatorTests|FullyQualifiedName~BreadcrumbSelectorCoordinatorTests" (the P12-T3 run; results under SCRATCH) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: in the P12-T3 fail-before run, the updated test `QuickFiler.Test.Viewers.BreadcrumbBridgeCoordinatorTests.UnhandledRightArrow_RaisesUnhandledArrowRight` passed, while P12-T3 lists it among the tests expected to fail. A failing run of this test is not obtainable, for the reasons below. + +Scope: one test. The other three P12-T2 updated tests and all five G6 non-guard tests failed in the same run (qfc-closed-fail-before.2026-10-25T22-23.md). + +WhyFailingRunImpossible: P12-T2 changes this test only by opening the selector before the Right arrow, so that the AC10 closed-list "Right opens the list" change of Phase 13 does not alter what it observes. An open-list Right on a leaf raises `UnhandledArrow(Right)` exactly once in every relevant code state, so there is no code state between P12 and P13 in which the updated test can fail. The behaviour it asserts is the D-Q21 open-list pop-out, which Phase 11 (P11-T6) already delivered. Before #1007 the same observation also held, through the router's unhandled-arrow path. + +## Alternative proof (static, absence of a distinguishing state) + +The test asserts one observation: after `OpenSelector()` and an inbound `arrowKey` Right on a single leaf row without children, `UnhandledArrow` is raised exactly once with `Right`. Each code state produces that observation: + +1. Pre-#1007 code (the plan base): the coordinator had no open-list arrow decision, so the arrow took `DispatchAsync`. `FolderBreadcrumbBridgeRouter.ArrowAsync` called `BreadcrumbStateModel.RightArrow()` on the committed row. A leaf without children cannot expand, so the router returned an `UnhandledArrowMessage(Right)` and `PublishRouterOutputs` raised `UnhandledArrow(Right)` once. The observation holds. +2. After Phase 11 (the state at P12-T3): `TryDispatchOpenArrowAsync` (in `BreadcrumbBridgeCoordinator.Keyboard.cs`) classifies the arrow through `ClassifyPendingArrow`. The pending entry has no children, so the result is `CancelAndPopOut`: `CancelSelector()`, then one `UnhandledArrow(Right)`. The observation holds. +3. After Phase 13: the closed-list half adds only closed-state branches, so the open-state path of item 2 is unchanged. The observation holds. + +The test therefore characterizes behaviour that holds in every state. It functions as a guard that keeps the AC6 pop-out observable once closed Right opens the list. The failing evidence for the AC6 behaviour itself is `OpenRight_OnLeaf_CancelsThenRaisesUnhandledArrowRight` in qfc-open-fail-before.2026-10-25T22-08.md, which failed before P11 because it also asserts the close-before-pop-out ordering. + +Detached-worktree run: not performed. Item 1 shows that the plan-base code produces the asserted observation, so a run of the updated test against that code would pass and could not serve as fail-before evidence. + +SearchScope: docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/ +SearchPatterns: fail-before-exception.*.md, qfc-closed-fail-before.*.md +SearchResult: this dossier and qfc-closed-fail-before.2026-10-25T22-23.md diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/n1-direct-tests.2026-10-25T23-58.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/n1-direct-tests.2026-10-25T23-58.md new file mode 100644 index 000000000..d1a24ae89 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/n1-direct-tests.2026-10-25T23-58.md @@ -0,0 +1,22 @@ +# N-1 Direct Tests (Issue #1007, remediation cycle 1, P3-T8) + +Timestamp: 2026-10-25T23-58 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true, then vstest.console.exe QuickFiler.Test.dll UtilitiesCS.Test.dll /InIsolation /Settings:scripts/vscode/TaskMaster.cli.runsettings "/TestCaseFilter:FullyQualifiedName~BreadcrumbBridgeRouterIssue1007Tests|FullyQualifiedName~FolderBreadcrumbBridgeRouterIssue1007Tests|FullyQualifiedName~BreadcrumbStateModelTests|FullyQualifiedName~BreadcrumbBridgeCoordinatorIssue1007Tests" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: The ANALYZE build reported `Build succeeded.`, `0 Warning(s)`, `0 Error(s)`. Test Run Successful: 113 total, 113 passed, 0 failed. The four P3 tests passed on their first run, as expected for characterization tests of unchanged behavior. + +## Trx-derived summary + +| Figure | Value | +|---|---| +| Total | 113 | +| Executed | 113 | +| Passed | 113 | +| Failed | 0 | + +| New test (task) | Outcome | +|---|---| +| LeafExpandToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChain (P3-T1) | Passed | +| AffordanceToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChainWithoutQuery (P3-T2) | Passed | +| RightArrow_OnRowCollapsedAwayFromActiveSegment_RestoresChain (P3-T4) | Passed | +| OpenUnhandledArrowLeft_OnExpandedPendingRow_RaisesUnhandledArrowFromRouterOutput (P3-T6) | Passed | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/node-suites-run.2026-10-25T22-50.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/node-suites-run.2026-10-25T22-50.md new file mode 100644 index 000000000..13e10bcc0 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/node-suites-run.2026-10-25T22-50.md @@ -0,0 +1,19 @@ +# Node Suites in Five File Orders (Issue #1007, P18-T6) + +Timestamp: 2026-10-25T22-50 +Command: node --test --test-concurrency=1 --test-reporter=spec [--experimental-test-coverage --test-coverage-include=QuickFiler/Resources/FolderBreadcrumb.js on the normal run] ; the shuffled orders come from a helper under SCRATCH that applies a Fisher-Yates shuffle driven by System.Random(seed), so a seed always yields the same order +EXIT_CODE: 0 +Output Summary: five orders (normal, reversed, and shuffles with seeds 1007, 20261025 and 424242). Each run: tests 90, pass 90, fail 0, node exit 0. `FolderBreadcrumb.js` line coverage on the normal run: 100.00 percent (branch 100.00, functions 100.00), against 100.00 percent at P0-T7 (baseline-node-suites.2026-10-23T13-41.md), so coverage is unchanged while the script grew from 350 to 393 lines. Summary derived from the node spec output and its coverage table. + +| Order | Seed | File order (execution order, concurrency 1) | Tests | Pass | Fail | Exit | +|---|---|---|---|---|---|---| +| normal (coverage) | - | issue-997-common, issue-997-recursive, issue-1002-nesting, issue-1007-keys | 90 | 90 | 0 | 0 | +| reversed | - | issue-1007-keys, issue-1002-nesting, issue-997-recursive, issue-997-common | 90 | 90 | 0 | 0 | +| shuffle | 1007 | issue-1002-nesting, issue-997-recursive, issue-997-common, issue-1007-keys | 90 | 90 | 0 | 0 | +| shuffle | 20261025 | issue-1007-keys, issue-997-recursive, issue-1002-nesting, issue-997-common | 90 | 90 | 0 | 0 | +| shuffle | 424242 | issue-1002-nesting, issue-1007-keys, issue-997-common, issue-997-recursive | 90 | 90 | 0 | 0 | + +| Coverage of `QuickFiler/Resources/FolderBreadcrumb.js` | Line % | Branch % | Functions % | +|---|---|---|---| +| P0-T7 baseline (3 suites) | 100.00 | 100.00 | 100.00 | +| P18-T6 normal run (4 suites) | 100.00 | 100.00 | 100.00 | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/node-suites-run.2026-10-26T00-05.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/node-suites-run.2026-10-26T00-05.md new file mode 100644 index 000000000..b0d3d357c --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/node-suites-run.2026-10-26T00-05.md @@ -0,0 +1,13 @@ +# Node Suites, Normal and Reversed Order (Issue #1007, remediation cycle 1, P5-T8) + +Timestamp: 2026-10-26T00-05 +Command: node --test --test-concurrency=1 , run once per order from the item worktree +EXIT_CODE: 0 +Output Summary: Both orders had tests 90, pass 90, fail 0, node exit 0. The count equals the 90 recorded in `node-suites-run.2026-10-25T22-50.md`, so the stylesheet edit left the page-script suites unchanged. Summary derived from the node reporter totals. + +| Order | File order (execution order, concurrency 1) | Tests | Pass | Fail | Exit | +|---|---|---|---|---|---| +| normal | issue-997-common, issue-997-recursive, issue-1002-nesting, issue-1007-keys | 90 | 90 | 0 | 0 | +| reversed | issue-1007-keys, issue-1002-nesting, issue-997-recursive, issue-997-common | 90 | 90 | 0 | 0 | + +Reference count: `node-suites-run.2026-10-25T22-50.md` records 90 tests per order. Both runs match it. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/page-scripts-fail-before.2026-10-25T22-38.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/page-scripts-fail-before.2026-10-25T22-38.md new file mode 100644 index 000000000..6292c3bfa --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/page-scripts-fail-before.2026-10-25T22-38.md @@ -0,0 +1,57 @@ +# Page-Script Fail-Before (Issue #1007, P16-T7) + +Timestamp: 2026-10-25T22-38 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~FolderBreadcrumbKeysIssue1007Tests|FullyQualifiedName~BreadcrumbBridgeJsKeysIssue1007Tests|FullyQualifiedName~FolderBreadcrumbAssetContractTests" (results under SCRATCH); then node --test --test-concurrency=1 --test-reporter=spec tests/quickfiler-breadcrumb/issue-997-common.test.mjs tests/quickfiler-breadcrumb/issue-997-recursive.test.mjs tests/quickfiler-breadcrumb/issue-1002-nesting.test.mjs tests/quickfiler-breadcrumb/issue-1007-keys.test.mjs +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. C# run: total 29, passed 13, failed 16: every G8 non-guard C# test (8 QFC, 5 EFC) and the three P16-T6 updated tests failed; the EFC guard `BridgeJs_NoSelection_PostsEmptyRowId` passed, as did the 12 unchanged asset-contract tests. Node run: tests 90, pass 75, fail 15: all ten G8 node tests failed, and so did the five P16-T3 updated cases whose expectation changed; the other 75 cases passed. vstest exit 1, node exit 1. Summaries derived from the trx and the node spec output. + +Run notes: +- The first rebuild of this pass failed with CS8370 because `QuickFiler.Test` compiles at C# 7.3, and the new `FolderBreadcrumbKeysIssue1007Tests.cs` carried `#nullable enable`. The directive was removed; the other new QuickFiler.Test files of this plan omit it for the same reason. The run above followed a clean rebuild. +- In the first node run, `ctrl+ArrowRight posts arrowKey right` passed on the pre-fix script. Its suggestion-row fixture already posted `arrowKey` because the old script never read `ctrlKey`, so the test did not exercise Ctrl+Right. The fixture was changed, within P16-T2, to a plain row, where the pre-fix pre-filter posts `unhandledArrow`; the run above is the re-run. +- P16-T3 changed the expectation of four of the twelve arrow cases (`plain` and `empty`, left and right); the other eight already expected `arrowKey`, so their passing outcome is unchanged. + +## C# string contracts + +| Outcome | Class | Test | +|---|---|---| +| Failed | FolderBreadcrumbKeysIssue1007Tests | SelectorKeys_MapHomeEndPageUpPageDown (selectorKeys has no Home entry) | +| Failed | FolderBreadcrumbKeysIssue1007Tests | CtrlArrowLeft_PostsParentSelectorKey (no ctrl+ArrowLeft branch) | +| Failed | FolderBreadcrumbKeysIssue1007Tests | PageKeys_CarryPageSizeFromFullyVisibleCount (no fullyVisibleCount function) | +| Failed | FolderBreadcrumbKeysIssue1007Tests | OnArrow_AlwaysPostsArrowKey (onArrow still pre-filters) | +| Failed | FolderBreadcrumbKeysIssue1007Tests | MakeSubfolders_MarksPendingNodeActive (no pendingNodeIdentity state) | +| Failed | FolderBreadcrumbKeysIssue1007Tests | NonLeafSegmentClick_OpenList_StopsPropagationAndPostsSelectorHighlight (no selectorHighlight) | +| Failed | FolderBreadcrumbKeysIssue1007Tests | SegmentDoubleClick_PostsSelectorSegmentDoubleClick (still segmentDoubleClick) | +| Failed | FolderBreadcrumbKeysIssue1007Tests | MakeSubfolders_LoadedChildWithoutChildren_RendersNoToggle (no childrenLoaded check) | +| Failed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_CtrlArrowLeft_PostsParentLeft (no ParentLeft rewrite) | +| Failed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_CtrlArrowRight_PostsRight (no ctrl branch) | +| Failed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_HomeEndPageKeys_MapToKeyValues (key map lacks Home) | +| Failed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_PageKeys_CarryMeasuredPageSize (no pageSize) | +| Failed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_Scroll_TargetsSelectedChildBeforeSelectedRow (scrolls the row only) | +| Passed (guard) | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_NoSelection_PostsEmptyRowId | +| Failed (P16-T6) | FolderBreadcrumbAssetContractTests | SelectorKeys_PreventBrowserScrollingAndPostNativeKeyMessages | +| Failed (P16-T6) | FolderBreadcrumbAssetContractTests | RenderReceiver_OwnsSelectedChildExpandedAndCollapsedProjection | +| Failed (P16-T6) | FolderBreadcrumbAssetContractTests | LeftAndRightBreadcrumbMessages_RemainSupported | + +## Node suites + +| Outcome | File | Test | +|---|---|---| +| Failed | issue-1007-keys.test.mjs | ctrl+ArrowLeft posts the parent selector key | +| Failed | issue-1007-keys.test.mjs | ctrl+ArrowRight posts arrowKey right | +| Failed | issue-1007-keys.test.mjs | Home and End post selector keys | +| Failed | issue-1007-keys.test.mjs | PageUp and PageDown post the fully visible row count as pageSize | +| Failed | issue-1007-keys.test.mjs | fullyVisibleCount counts only fully visible rows with a minimum of one | +| Failed | issue-1007-keys.test.mjs | ArrowLeft and ArrowRight always post arrowKey | +| Failed | issue-1007-keys.test.mjs | pending node carries sub active | +| Failed | issue-1007-keys.test.mjs | single click on a non-leaf segment in the open list posts selectorHighlight without bubbling | +| Failed | issue-1007-keys.test.mjs | segment double-click posts selectorSegmentDoubleClick with the row identity | +| Failed | issue-1007-keys.test.mjs | loaded child with zero children renders no toggle | +| Failed (P16-T3) | issue-997-common.test.mjs | row cells retain literal names, title, truncation and host percentage | +| Failed (P16-T3) | issue-997-common.test.mjs | arrow left routes plain state through host contract | +| Failed (P16-T3) | issue-997-common.test.mjs | arrow left routes empty state through host contract | +| Failed (P16-T3) | issue-997-common.test.mjs | arrow right routes plain state through host contract | +| Failed (P16-T3) | issue-997-common.test.mjs | arrow right routes empty state through host contract | +| Passed (P16-T3, expectation unchanged) | issue-997-common.test.mjs | arrow left and right routes suggestion, collapsed, expanded and affordance state (8 cases) | +| Passed (unchanged) | issue-997-common.test.mjs | clicking a selectable row uses its host identity | +| Passed (unchanged, P16-T1 harness default) | issue-997-recursive.test.mjs, issue-1002-nesting.test.mjs | every case (28) | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/page-scripts-pass-after.2026-10-25T22-42.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/page-scripts-pass-after.2026-10-25T22-42.md new file mode 100644 index 000000000..2b70dc9ef --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/page-scripts-pass-after.2026-10-25T22-42.md @@ -0,0 +1,50 @@ +# Page-Script Pass-After (Issue #1007, P17-T3) + +Timestamp: 2026-10-25T22-42 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~Breadcrumb|FullyQualifiedName~OutlookFolderHierarchyProvider" (results under SCRATCH); then node --test --test-concurrency=1 --test-reporter=spec tests/quickfiler-breadcrumb/issue-997-common.test.mjs tests/quickfiler-breadcrumb/issue-997-recursive.test.mjs tests/quickfiler-breadcrumb/issue-1002-nesting.test.mjs tests/quickfiler-breadcrumb/issue-1007-keys.test.mjs +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. BREADCRUMB-FILTER run: total 1073, executed 1073, passed 1073, failed 0. Node run: tests 90, pass 90, fail 0. Every G8 test (8 QFC and 6 EFC C# contracts, 10 node tests) passed. The unchanged `FolderBreadcrumbNestingIssue1002Tests`, `FolderBreadcrumbRecursiveAssetIssue997Tests` and the `BridgeJs` test `Issue995BridgeJsPostsConfirmationForPlainEnterAndScrollsSelectedRowIntoView` also passed. `FolderBreadcrumb.js` is 393 physical lines (350 at P0-T8); `BreadcrumbDocumentAssets.cs` is 201. Summaries derived from the trx and the node spec output. + +Run note: the first pass-after run (same commands) had one C# failure, the unchanged `FolderBreadcrumbRecursiveAssetIssue997Tests.RenderDescendantWithChildren_OffersIndependentExpansionControl`. It requires the literal `if (child.hasChildren)` guard ahead of `selectorDescendantToggle`, and the first P17-T2 draft had folded the AC14 check into that condition. The script now keeps `if (child.hasChildren)` unchanged and attaches the toggle only under `if (!loadedEmpty)`. The twin `MakeSubfolders_LoadedChildWithoutChildren_RendersNoToggle` (P16-T4) was adjusted to assert that guard; it still fails on the pre-fix script, because that script has no `childrenLoaded` check (page-scripts-fail-before.2026-10-25T22-38.md). The P17-T2 structural constraints hold: no function was added inside `makeSubfolders`, `subfolderElement.appendChild(name)` still precedes `subfolderElement.appendChild(toggle)`, and the recursive call `makeSubfolders(row, child.children, selectedChildOwnsActive, depth + 1)` is unchanged. `fullyVisibleCount`, `visiblePageSize` and `lastSegmentIndex` are declared outside the region from `function makeSubfolders(` to `function render`. + +## C# string contracts (G8 and the P16-T6 updated tests) + +| Outcome | Class | Test | +|---|---|---| +| Passed | FolderBreadcrumbKeysIssue1007Tests | SelectorKeys_MapHomeEndPageUpPageDown | +| Passed | FolderBreadcrumbKeysIssue1007Tests | CtrlArrowLeft_PostsParentSelectorKey | +| Passed | FolderBreadcrumbKeysIssue1007Tests | PageKeys_CarryPageSizeFromFullyVisibleCount | +| Passed | FolderBreadcrumbKeysIssue1007Tests | OnArrow_AlwaysPostsArrowKey | +| Passed | FolderBreadcrumbKeysIssue1007Tests | MakeSubfolders_MarksPendingNodeActive | +| Passed | FolderBreadcrumbKeysIssue1007Tests | NonLeafSegmentClick_OpenList_StopsPropagationAndPostsSelectorHighlight | +| Passed | FolderBreadcrumbKeysIssue1007Tests | SegmentDoubleClick_PostsSelectorSegmentDoubleClick | +| Passed | FolderBreadcrumbKeysIssue1007Tests | MakeSubfolders_LoadedChildWithoutChildren_RendersNoToggle | +| Passed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_CtrlArrowLeft_PostsParentLeft | +| Passed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_CtrlArrowRight_PostsRight | +| Passed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_HomeEndPageKeys_MapToKeyValues | +| Passed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_PageKeys_CarryMeasuredPageSize | +| Passed | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_Scroll_TargetsSelectedChildBeforeSelectedRow | +| Passed (guard) | BreadcrumbBridgeJsKeysIssue1007Tests | BridgeJs_NoSelection_PostsEmptyRowId | +| Passed (P16-T6) | FolderBreadcrumbAssetContractTests | SelectorKeys_PreventBrowserScrollingAndPostNativeKeyMessages | +| Passed (P16-T6) | FolderBreadcrumbAssetContractTests | RenderReceiver_OwnsSelectedChildExpandedAndCollapsedProjection | +| Passed (P16-T6) | FolderBreadcrumbAssetContractTests | LeftAndRightBreadcrumbMessages_RemainSupported | +| Passed (unchanged) | FolderBreadcrumbNestingIssue1002Tests | MakeSubfolders_AppendsNameBeforeToggle, MakeSubfolders_PinnedRecursiveCallPrefixPreserved, MakeSubfolders_RecursiveCallPassesDepthPlusOne, MakeSubfolders_SetsDataDepthAndPerDepthPadding | +| Passed (unchanged) | FolderBreadcrumbRecursiveAssetIssue997Tests | RenderDescendantWithChildren_OffersIndependentExpansionControl, ComposedDocument_ContainsExactlyOneInlineScript, ComposedDocument_InsertsCompleteCompiledScriptWithoutMarker | +| Passed (unchanged) | BreadcrumbHtmlRendererTests | Issue995BridgeJsPostsConfirmationForPlainEnterAndScrollsSelectedRowIntoView | + +## Node suites + +| Outcome | File | Test | +|---|---|---| +| Passed | issue-1007-keys.test.mjs | ctrl+ArrowLeft posts the parent selector key | +| Passed | issue-1007-keys.test.mjs | ctrl+ArrowRight posts arrowKey right | +| Passed | issue-1007-keys.test.mjs | Home and End post selector keys | +| Passed | issue-1007-keys.test.mjs | PageUp and PageDown post the fully visible row count as pageSize | +| Passed | issue-1007-keys.test.mjs | fullyVisibleCount counts only fully visible rows with a minimum of one | +| Passed | issue-1007-keys.test.mjs | ArrowLeft and ArrowRight always post arrowKey | +| Passed | issue-1007-keys.test.mjs | pending node carries sub active | +| Passed | issue-1007-keys.test.mjs | single click on a non-leaf segment in the open list posts selectorHighlight without bubbling | +| Passed | issue-1007-keys.test.mjs | segment double-click posts selectorSegmentDoubleClick with the row identity | +| Passed | issue-1007-keys.test.mjs | loaded child with zero children renders no toggle | +| Passed (P16-T3) | issue-997-common.test.mjs | row cells retain literal names, title, truncation and host percentage; the 12 arrow cases | +| Passed (unchanged) | issue-997-common.test.mjs, issue-997-recursive.test.mjs, issue-1002-nesting.test.mjs | every other case | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-clicks-fail-before.2026-10-25T22-29.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-clicks-fail-before.2026-10-25T22-29.md new file mode 100644 index 000000000..eca3268a7 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-clicks-fail-before.2026-10-25T22-29.md @@ -0,0 +1,26 @@ +# QFC Click and Double-Click Fail-Before (Issue #1007, P14-T4) + +Timestamp: 2026-10-25T22-29 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~BreadcrumbBridgeCoordinatorIssue1007Tests|FullyQualifiedName~FolderBreadcrumbBridgeRouterIssue1007Tests|FullyQualifiedName~BreadcrumbSelectorMessagesTests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.FolderBreadcrumbBridgeRouterTests|FullyQualifiedName~QuickFiler.Test.Viewers.BreadcrumbBridgeCoordinatorTests" (results under SCRATCH; `FolderBreadcrumbBridgeRouterTests` includes its `FolderBreadcrumbBridgeRouterInFlightTests.cs` partial) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. Tests: total 119, executed 119, passed 103, failed 16. Every G7 test (4 coordinator, 6 router, 3 serializer) and all three P14-T3 rewritten tests failed; the 103 passing rows are the unchanged and earlier-group tests of the same classes. Summary derived from the trx. + +| Outcome | Class | Test | Failure message (from the trx, shortened) | +|---|---|---|---| +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorSegmentDoubleClick_CommitsSegmentFolderAndCloses | Expected the Projects path, but found the Beta path. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorSegmentDoubleClick_Closed_CommitsSegmentFolder | Expected the Projects path, but found the Beta path. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorSegmentDoubleClick_ThenAffordanceToggle_ListsSegmentChildren | Expected the last render to list Omega on a shortened row; the row rendered unshortened with the leaf children. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorHighlight_Open_MovesPendingOnlyAndKeepsListOpen | Expected the pending identity to be the Beta row, but found the Alpha row. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | DoubleClickSelectorSegment_ActivatesCommitsAndShortens | NotImplementedException. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | DoubleClickSelectorSegment_UnknownRowOrIndex_IsUnhandled | NotImplementedException. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | AffordanceToggle_OnShortenedRow_ExpandsActiveSegmentWithOneQuery | NotImplementedException. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | ShortenedRowExpanded_ProjectsMinusAtTerminal | NotImplementedException. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | BridgeSegmentDoubleClick_IsLoggedNoOpWithoutStateChange | Expected outputs to be empty, but found a collapsed render. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | HighlightSelector_Closed_IsUnhandled | NotImplementedException. | +| Failed | BreadcrumbSelectorMessagesTests | SegmentDoubleClickMessage_RoundTripsRowIdentityAndSegmentIndex | FormatException from Serialize/Parse (no wire form yet). | +| Failed | BreadcrumbSelectorMessagesTests | HighlightMessage_RoundTripsIdentity | FormatException from Serialize/Parse (no wire form yet). | +| Failed | BreadcrumbSelectorMessagesTests | Parse_SegmentDoubleClickWithNegativeIndex_RejectsExplicitly | Expected the message to name segmentIndex, but found "Unknown selector message type 'selectorSegmentDoubleClick'." | +| Failed (P14-T3) | FolderBreadcrumbBridgeRouterTests | Route_SegmentDoubleClick_ProducesCollapsedRenderPayload | Expected outputs to be empty, but found a collapsed render. | +| Failed (P14-T3) | FolderBreadcrumbBridgeRouterTests (InFlight partial) | Sequence_ExpandCollapseViaMessages_TransitionsDeterministically | NotImplementedException. | +| Failed (P14-T3) | BreadcrumbBridgeCoordinatorTests | InboundDoubleClick_PostsCollapsedRenderJson | InvalidOperationException: sequence contains no render. | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-clicks-pass-after.2026-10-25T22-32.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-clicks-pass-after.2026-10-25T22-32.md new file mode 100644 index 000000000..747c88dff --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-clicks-pass-after.2026-10-25T22-32.md @@ -0,0 +1,27 @@ +# QFC Click and Double-Click Pass-After (Issue #1007, P15-T4) + +Timestamp: 2026-10-25T22-32 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~Breadcrumb|FullyQualifiedName~OutlookFolderHierarchyProvider" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. BREADCRUMB-FILTER run: total 1059, executed 1059, passed 1059, failed 0. Every G7 test (13) and every Phase 14 rewritten test (3) passed. Summary derived from the trx. + +Implementation notes: P15-T1 parses and serializes `selectorSegmentDoubleClick` and `selectorHighlight` with constructor guards (`BreadcrumbSelectorMessages.cs` 476 physical lines). P15-T2 implements `DoubleClickSelectorSegment` and `HighlightSelector` through `BreadcrumbSelectionSession.DoubleClickSegment` and `HighlightIdentity`, and replaces the bridge `segmentDoubleClick` arm with a logged (debug level) no-op returning no output; `FolderBreadcrumbBridgeRouter.cs` is 403 physical lines (404 at P0-T8). P15-T3 applies R3 in `ToggleAsync`, projects `Minus` at the shortened terminal while expanded, and dispatches the two selector click messages from `HandleSelectorMessage` into `BreadcrumbBridgeCoordinator.Keyboard.cs` (`BreadcrumbBridgeCoordinator.cs` 438 physical lines). + +| Outcome | Class | Test | +|---|---|---| +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorSegmentDoubleClick_CommitsSegmentFolderAndCloses | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorSegmentDoubleClick_Closed_CommitsSegmentFolder | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorSegmentDoubleClick_ThenAffordanceToggle_ListsSegmentChildren | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorHighlight_Open_MovesPendingOnlyAndKeepsListOpen | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | DoubleClickSelectorSegment_ActivatesCommitsAndShortens | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | DoubleClickSelectorSegment_UnknownRowOrIndex_IsUnhandled | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | AffordanceToggle_OnShortenedRow_ExpandsActiveSegmentWithOneQuery | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | ShortenedRowExpanded_ProjectsMinusAtTerminal | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | BridgeSegmentDoubleClick_IsLoggedNoOpWithoutStateChange | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | HighlightSelector_Closed_IsUnhandled | +| Passed | BreadcrumbSelectorMessagesTests | SegmentDoubleClickMessage_RoundTripsRowIdentityAndSegmentIndex | +| Passed | BreadcrumbSelectorMessagesTests | HighlightMessage_RoundTripsIdentity | +| Passed | BreadcrumbSelectorMessagesTests | Parse_SegmentDoubleClickWithNegativeIndex_RejectsExplicitly | +| Passed (P14-T3) | FolderBreadcrumbBridgeRouterTests | Route_SegmentDoubleClick_ProducesCollapsedRenderPayload | +| Passed (P14-T3) | FolderBreadcrumbBridgeRouterTests (InFlight partial) | Sequence_ExpandCollapseViaMessages_TransitionsDeterministically | +| Passed (P14-T3) | BreadcrumbBridgeCoordinatorTests | InboundDoubleClick_PostsCollapsedRenderJson | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-closed-fail-before.2026-10-25T22-23.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-closed-fail-before.2026-10-25T22-23.md new file mode 100644 index 000000000..2e3ee3a2b --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-closed-fail-before.2026-10-25T22-23.md @@ -0,0 +1,21 @@ +# QFC Closed-List Fail-Before (Issue #1007, P12-T3) + +Timestamp: 2026-10-25T22-23 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~BreadcrumbBridgeCoordinatorIssue1007Tests|FullyQualifiedName~QuickFiler.Test.Viewers.BreadcrumbBridgeCoordinatorTests|FullyQualifiedName~BreadcrumbSelectorCoordinatorTests" (results under SCRATCH) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. Tests: total 44, executed 44, passed 36, failed 8. Every G6 non-guard test (5) failed and both G6 guards passed. Three of the four P12-T2 updated tests failed; `UnhandledRightArrow_RaisesUnhandledArrowRight` passed, and the reason is recorded in fail-before-exception.2026-10-25T22-23.md in this folder. The remaining 28 passing rows are unchanged tests of the three classes. Summary derived from the trx. + +| Outcome | Class | Test | Failure message (from the trx, shortened) | +|---|---|---|---| +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedRight_OpensSelectorWithPendingOnVisibleCommittedSubfolder | Expected IsSelectorOpen to be True, but found False. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedRight_SubfolderHidden_PendingOnRow | Expected IsSelectorOpen to be True, but found False. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedLeft_IsConsumedWithoutEffect | Expected h.Events to be empty, but found {"unhandled:Left"}. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedHomeEnd_CommitFirstAndLastTopLevelRow | Expected the Beta path after End, but found the Alpha path. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyParent_Closed_CommitsImmediately | Expected the Projects path, but found the Alpha path. | +| Passed (guard) | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedPageKeys_AreNoOps | - | +| Passed (guard) | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedUpDown_KeepDirectCommitOfAdjacentTopLevelRow | - | +| Failed (P12-T2) | BreadcrumbBridgeCoordinatorTests | UnhandledLeftArrow_RaisesUnhandledArrowLeft | Expected directions to be empty, but found {Left}. | +| Failed (P12-T2) | BreadcrumbBridgeCoordinatorTests | ArrowMessages_RaiseSyntheticFolderKeyDown | Expected keys {Right}, but {Right, Left} contains 1 item too many. | +| Failed (P12-T2) | BreadcrumbSelectorCoordinatorTests | ExistingLeftAndRightMessages_StillForwardOnce | Expected synthetic to be 0, but found 1. | +| Passed (P12-T2, see exception dossier) | BreadcrumbBridgeCoordinatorTests | UnhandledRightArrow_RaisesUnhandledArrowRight | - | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-closed-pass-after.2026-10-25T22-25.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-closed-pass-after.2026-10-25T22-25.md new file mode 100644 index 000000000..a2d9b5a11 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-closed-pass-after.2026-10-25T22-25.md @@ -0,0 +1,22 @@ +# QFC Closed-List Pass-After (Issue #1007, P13-T3) + +Timestamp: 2026-10-25T22-25 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~Breadcrumb|FullyQualifiedName~OutlookFolderHierarchyProvider" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. BREADCRUMB-FILTER run: total 1046, executed 1046, passed 1046, failed 0. Every G6 test (7) and every Phase 12 updated test (4) passed. Summary derived from the trx. + +Implementation notes: P13-T1 implements `BreadcrumbSelectionSession.CommitBoundaryRow` (closed session only) and the D-Q19 closed-list Ctrl+Left commit (`MoveToParent` on a closed session commits the parent node, the row entry at depth 0, or the parent segment with the row shortened), exposed through `FolderBreadcrumbBridgeRouter.CommitSelectorBoundaryRow` and a closed-aware `MoveSelectorToParent`. P13-T2 adds the closed-list half of R8 to `BreadcrumbBridgeCoordinator.Keyboard.cs`: closed Right opens the list, closed Left is consumed, closed Home and End commit the first and last top-level row, closed page keys stay no-ops, and closed Up and Down keep `MoveSelector`. + +| Outcome | Class | Test | +|---|---|---| +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedRight_OpensSelectorWithPendingOnVisibleCommittedSubfolder | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedRight_SubfolderHidden_PendingOnRow | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedLeft_IsConsumedWithoutEffect | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedHomeEnd_CommitFirstAndLastTopLevelRow | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyParent_Closed_CommitsImmediately | +| Passed (guard) | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedPageKeys_AreNoOps | +| Passed (guard) | BreadcrumbBridgeCoordinatorIssue1007Tests | ClosedUpDown_KeepDirectCommitOfAdjacentTopLevelRow | +| Passed (P12-T2) | BreadcrumbBridgeCoordinatorTests | UnhandledRightArrow_RaisesUnhandledArrowRight | +| Passed (P12-T2) | BreadcrumbBridgeCoordinatorTests | UnhandledLeftArrow_RaisesUnhandledArrowLeft | +| Passed (P12-T2) | BreadcrumbBridgeCoordinatorTests | ArrowMessages_RaiseSyntheticFolderKeyDown | +| Passed (P12-T2) | BreadcrumbSelectorCoordinatorTests | ExistingLeftAndRightMessages_StillForwardOnce | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-open-fail-before.2026-10-25T22-08.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-open-fail-before.2026-10-25T22-08.md new file mode 100644 index 000000000..967868e90 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-open-fail-before.2026-10-25T22-08.md @@ -0,0 +1,58 @@ +# QFC Open-List Fail-Before (Issue #1007, P10-T6) + +Timestamp: 2026-10-25T22-08 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~BreadcrumbSelectionSessionTreeIssue1007Tests|FullyQualifiedName~FolderBreadcrumbBridgeRouterIssue1007Tests|FullyQualifiedName~BreadcrumbBridgeCoordinatorIssue1007Tests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.BreadcrumbStateModelTests|FullyQualifiedName~BreadcrumbSelectionSessionTests|FullyQualifiedName~BreadcrumbSelectorMessagesTests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.FolderBreadcrumbBridgeRouterTests" (results under SCRATCH) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. Tests: total 142, executed 142, passed 100, failed 42. Every G5 non-guard test (11 session, 8 router, 7 coordinator), the two G5 serializer tests, the five new `SelectorKeyMessage_RoundTripsOnlySupportedKeys` rows, and all nine rewritten tests failed; both guards and both summary-only edits passed. Summary derived from the trx. + +Run note: this run was resumed after the previous executor's host process exited. The first two attempts in this session discovered zero tests because the MSTest adapter failed to load ("The module was expected to contain an assembly manifest"). A byte scan found 2860 files under the project `bin/Debug` output folders that were zero-filled at their correct length, a state left by the abrupt host exit; MSBuild's incremental copy skipped them because their size and timestamp matched the package sources. The zero-filled output files were deleted individually (no source, `obj`, evidence or `packages/` file was zero-filled; 18291 files scanned), the solution was rebuilt with the command above, a re-scan found no binary without a valid header, and the run above is the re-run. The test sources were not changed between attempts. + +| Outcome | Class | Test | Failure message (from the trx, shortened) | +|---|---|---|---| +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | SelectorKeyDown_Open_MovesPendingThroughVisibleDescendants | Expected session.PendingNode to refer to a descendant node, but found null. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | SelectorKeyUp_Open_FromRowBelowExpandedBlock_PendsLastVisibleDescendant | Expected session.PendingNode to refer to a descendant node, but found null. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | CommitPending_WithPendingNode_SelectsDescendant | Expected model.SelectedDescendant to refer to a descendant node, but found null. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | Cancel_RestoresOriginalAndClearsPendingNode | Expected session.PendingNode not to be null because the pending highlight rests on Delta. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | MoveToBoundary_Open_PendsFirstAndLastVisibleEntry | NotImplementedException. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | MovePage_Open_MovesByPageSizeAndClamps | NotImplementedException. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | MovePage_InvalidPageSize_IsNoOp | NotImplementedException. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | MoveToParent_Open_OnNode_PendsParentKeepingExpansion | NotImplementedException. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | MoveToParent_Open_OnRow_ShortensPendingRowAtParentSegment | NotImplementedException. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | MoveToParent_AtFirstSegment_IsNoOp | NotImplementedException. | +| Failed | BreadcrumbSelectionSessionTreeIssue1007Tests | SelectorKeyParent_Open_ThenEscape_RestoresRowShapeAndValue | NotImplementedException. | +| Passed (guard) | BreadcrumbSelectionSessionTreeIssue1007Tests | SelectorKeyDown_Open_OnLastEntry_KeepsPending | - | +| Passed (guard) | BreadcrumbSelectionSessionTreeIssue1007Tests | OpenMove_DoesNotChangeCommittedValue | - | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | OpenRight_OnPendingRowWithChildren_ExpandsPendingRow | Expected Rows[0].LeafExpanded to be True, but found False. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | OpenRight_OnExpandedPendingEntry_PendsFirstChild | Expected PendingNodeIdentity to be the first child node identity, but found null. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | OpenLeft_OnExpandedPendingEntry_CollapsesAndKeepsPending | Expected ActiveSegmentIndex to be 2 because Left never selects a parent, but found 1. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | CollapseHidingPending_MovesPendingToNearestVisibleAncestor | Expected PendingNodeIdentity to be the ancestor node identity, but found null. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | ClassifyPendingArrow_OpenLeftOnNonExpanded_ReturnsCommitAndClose | NotImplementedException. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | ClassifyPendingArrow_OpenRightOnLeaf_ReturnsCancelAndPopOut | NotImplementedException. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnShortenedPendingRow_ListsSegmentChildren | NotImplementedException. | +| Failed | FolderBreadcrumbBridgeRouterIssue1007Tests | GetSelectedFolder_ShortenedRow_ReturnsActiveSegmentValue | Expected the Projects value, but found the leaf value ending Projects\Alpha. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | OpenLeft_OnNonExpandedEntry_CommitsAndCloses | Expected IsSelectorOpen to be False, but found True. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | OpenRight_OnLeaf_CancelsThenRaisesUnhandledArrowRight | Expected events {"open-state", "unhandled:Right"}, but found an empty collection. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyParent_Open_MovesPendingOnly | Expected the last render to show the shortened pending row; it was unchanged. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyHomeEnd_Open_MovePendingToFirstAndLastVisibleEntry | Expected the pending identity after End to be the last row; it stayed on the first. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyPageDown_Open_MovesPendingByPageSize | Expected the last view's pending identity to move by the page size; it did not move. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorView_PendingNode_CarriesPendingNodeIdentity | Expected the last view to carry pendingNodeIdentity; it was absent. | +| Failed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyEnter_Open_WithPendingNode_CommitsDescendantAndRaisesSelectionChanged | Expected the descendant path ending Alpha\Specs, but found the row path ending Clients\Beta. | +| Failed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_PageSize_RoundTrips | FormatException from Parse (page keys are not yet accepted). | +| Failed | BreadcrumbSelectorMessagesTests | ViewMessage_PendingNodeIdentity_RoundTrips | Expected parsed.PendingNodeIdentity to be "node-1", but found null. | +| Failed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("Parent") | FormatException from Parse. | +| Failed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("Home") | FormatException from Parse. | +| Failed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("End") | FormatException from Parse. | +| Failed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("PageUp") | FormatException from Parse. | +| Failed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("PageDown") | FormatException from Parse. | +| Failed (P10-T4) | BreadcrumbStateModelTests | LeftArrow_QfcMultiSegmentRow_SelectsParentNode | Expected handled to be False because the #440 parent walk is removed, but found True. | +| Failed (P10-T4) | BreadcrumbStateModelTests | RightArrow_QfcSelectedParentNode_ExpandsIntoChildren | Expected CollapsedAfterIndex to be 1 because the row stays shortened, but found null. | +| Failed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | Arrows_RightExpandsThenLeftCollapses_UnhandledWhenNothingChanges | Expected ActiveSegmentIndex to be 2, but found 1. | +| Failed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | RightArrow_OnCollapsedRow_ReExpandsBeforeLeafExpansion | Expected CollapsedAfterIndex to be 0, but found null. | +| Failed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | LeftArrow_RepeatedOnThreeSegmentChain_WalksToRootThenReportsUnhandled | Expected model.LeftArrow() to be False, but found True. | +| Failed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | LeftArrow_WalkFromAnOpenLeafExpansion_ClearsTheExpansionAndStillReachesTheRoot | Expected ActiveSegmentIndex to be 2, but found 1. | +| Passed (P10-T4, summary only) | BreadcrumbStateModelTests (Sequence partial) | LeftArrow_WithSubfolderSelected_ResetsSubfolderSelectionAndCollapses | - | +| Passed (P10-T4, summary only) | BreadcrumbSelectionSessionTests | BreadcrumbLeftAndRightTransitions_DoNotMutateSelectorSession | - | +| Failed (P10-T5) | FolderBreadcrumbBridgeRouterTests | Route_LeftArrow_NothingToCollapse_ReportsUnhandledLeft | InvalidCastException: a RenderMessage was returned where UnhandledArrowMessage was expected. | +| Failed (P10-T5) | FolderBreadcrumbBridgeRouterTests | ArrowAsync_QfcLeftOnMultiSegmentRow_RoutesParentSelectTransition | Expected UnhandledArrowMessage, but found RenderMessage. | +| Failed (P10-T5) | FolderBreadcrumbBridgeRouterTests | ArrowAsync_QfcRightOnSelectedParentNode_RoutesChildExpansion | Expected Rows[0].CollapsedAfterIndex to be 1, but found null. | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-open-pass-after.2026-10-25T22-19.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-open-pass-after.2026-10-25T22-19.md new file mode 100644 index 000000000..894bff9d8 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-open-pass-after.2026-10-25T22-19.md @@ -0,0 +1,67 @@ +# QFC Open-List Pass-After (Issue #1007, P11-T7) + +Timestamp: 2026-10-25T22-19 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~Breadcrumb|FullyQualifiedName~OutlookFolderHierarchyProvider" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. BREADCRUMB-FILTER run: total 1039, executed 1039, passed 1039, failed 0. Every G5 test (13 session, 8 router, 7 coordinator, 2 serializer tests and the 5 new key rows) and every Phase 10 edited test passed. BASELINE-FAILURES is empty, so no failure is excused. Summary derived from the trx. + +Run note: the first run of this pass (same build command) had 4 failures, all in the unchanged `BreadcrumbSelectorCoordinatorTests` (`BoundaryAndInvalidOperations_AreDeterministicNoOps`, `ClosedDown_CommitsNextSelectableAndRaisesOneSelection`, `EscapeAndUncommittedClose_RestoreOpeningSelectionWithoutNotification`, `OpenDown_ChangesPendingOnlyThenEnterCommitsAndCloses`) with `TargetParameterCountException`. That class binds `HandleSelectorKey` by name through reflection with one argument, so the optional `pageSize` parameter named in R8 broke the existing public signature. The fix keeps the public one-argument `HandleSelectorKey(BreadcrumbSelectorKey)` unchanged and carries the page size through a private `HandleSelectorKeyCore(key, pageSize)` that `HandleSelectorMessage` calls with `key.PageSize`. The R8 behaviour (page keys move by the page size carried by the `selectorKey` message) is unchanged; only the signature placement differs from the R8 wording. The run above is the re-run after CSharpier and the same rebuild. + +Implementation notes for P11-T1 to P11-T6: +- P11-T1: `ParseKey` accepts every lowercase key name (the spelling `Serialize` writes); `pageSize` is read as an optional non-negative integer and written when present; `pendingNodeIdentity` is read and written on `selectorView`, and a blank value is rejected by the constructor guard. `BreadcrumbSelectorMessages.cs` is 445 physical lines. +- P11-T2: the #440 walks and `TryRightTreeTransition` are removed; Right applies R3 through `BreadcrumbStateModel.Expand`; `ActiveSegmentHasSubfolders` accepts `CollapsedAfterIndex == ActiveSegmentIndex`. `BreadcrumbStateModel.Row.cs` is 411 physical lines (408 at P0-T8, +3). +- P11-T3: `BreadcrumbSelectionSession.cs` gained call-throughs only (open snapshot, open-list `Move`, `CommitPending` node commit, `Cancel` shape restore, `EndOpenSession` clear, `Snapshot` pending node, and a `PendingIdentity` setter that clears the pending node); 492 physical lines. +- P11-T6: `BreadcrumbBridgeCoordinator.cs` is 435 physical lines; `BreadcrumbBridgeCoordinator.Keyboard.cs` is registered in `QuickFiler/QuickFiler.csproj`. + +| Outcome | Class | Test | +|---|---|---| +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | SelectorKeyDown_Open_MovesPendingThroughVisibleDescendants | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | SelectorKeyUp_Open_FromRowBelowExpandedBlock_PendsLastVisibleDescendant | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | CommitPending_WithPendingNode_SelectsDescendant | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | Cancel_RestoresOriginalAndClearsPendingNode | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | MoveToBoundary_Open_PendsFirstAndLastVisibleEntry | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | MovePage_Open_MovesByPageSizeAndClamps | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | MovePage_InvalidPageSize_IsNoOp | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | MoveToParent_Open_OnNode_PendsParentKeepingExpansion | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | MoveToParent_Open_OnRow_ShortensPendingRowAtParentSegment | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | MoveToParent_AtFirstSegment_IsNoOp | +| Passed | BreadcrumbSelectionSessionTreeIssue1007Tests | SelectorKeyParent_Open_ThenEscape_RestoresRowShapeAndValue | +| Passed (guard) | BreadcrumbSelectionSessionTreeIssue1007Tests | SelectorKeyDown_Open_OnLastEntry_KeepsPending | +| Passed (guard) | BreadcrumbSelectionSessionTreeIssue1007Tests | OpenMove_DoesNotChangeCommittedValue | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | OpenRight_OnPendingRowWithChildren_ExpandsPendingRow | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | OpenRight_OnExpandedPendingEntry_PendsFirstChild | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | OpenLeft_OnExpandedPendingEntry_CollapsesAndKeepsPending | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | CollapseHidingPending_MovesPendingToNearestVisibleAncestor | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | ClassifyPendingArrow_OpenLeftOnNonExpanded_ReturnsCommitAndClose | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | ClassifyPendingArrow_OpenRightOnLeaf_ReturnsCancelAndPopOut | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | ArrowRight_OnShortenedPendingRow_ListsSegmentChildren | +| Passed | FolderBreadcrumbBridgeRouterIssue1007Tests | GetSelectedFolder_ShortenedRow_ReturnsActiveSegmentValue | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | OpenLeft_OnNonExpandedEntry_CommitsAndCloses | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | OpenRight_OnLeaf_CancelsThenRaisesUnhandledArrowRight | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyParent_Open_MovesPendingOnly | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyHomeEnd_Open_MovePendingToFirstAndLastVisibleEntry | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyPageDown_Open_MovesPendingByPageSize | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorView_PendingNode_CarriesPendingNodeIdentity | +| Passed | BreadcrumbBridgeCoordinatorIssue1007Tests | SelectorKeyEnter_Open_WithPendingNode_CommitsDescendantAndRaisesSelectionChanged | +| Passed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_PageSize_RoundTrips | +| Passed | BreadcrumbSelectorMessagesTests | ViewMessage_PendingNodeIdentity_RoundTrips | +| Passed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("Parent") | +| Passed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("Home") | +| Passed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("End") | +| Passed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("PageUp") | +| Passed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("PageDown") | +| Passed | BreadcrumbSelectorMessagesTests | SelectorKeyMessage_RoundTripsOnlySupportedKeys ("Up", "Down", "Enter", "Escape"; unchanged rows) | +| Passed (P10-T4) | BreadcrumbStateModelTests | LeftArrow_QfcMultiSegmentRow_SelectsParentNode | +| Passed (P10-T4) | BreadcrumbStateModelTests | RightArrow_QfcSelectedParentNode_ExpandsIntoChildren | +| Passed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | Arrows_RightExpandsThenLeftCollapses_UnhandledWhenNothingChanges | +| Passed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | RightArrow_OnCollapsedRow_ReExpandsBeforeLeafExpansion | +| Passed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | LeftArrow_RepeatedOnThreeSegmentChain_WalksToRootThenReportsUnhandled | +| Passed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | LeftArrow_WalkFromAnOpenLeafExpansion_ClearsTheExpansionAndStillReachesTheRoot | +| Passed (P10-T4) | BreadcrumbStateModelTests (Sequence partial) | LeftArrow_WithSubfolderSelected_ResetsSubfolderSelectionAndCollapses | +| Passed (P10-T4) | BreadcrumbSelectionSessionTests | BreadcrumbLeftAndRightTransitions_DoNotMutateSelectorSession | +| Passed (P10-T5) | FolderBreadcrumbBridgeRouterTests | Route_LeftArrow_NothingToCollapse_ReportsUnhandledLeft | +| Passed (P10-T5) | FolderBreadcrumbBridgeRouterTests | ArrowAsync_QfcLeftOnMultiSegmentRow_RoutesParentSelectTransition | +| Passed (P10-T5) | FolderBreadcrumbBridgeRouterTests | ArrowAsync_QfcRightOnSelectedParentNode_RoutesChildExpansion | +| Passed (unaffected, D-Q29) | BreadcrumbSubfolderActivationTests | OpenSelector_SubfolderActivation_PublishesOneDurableRenderAndNoLegacySelectionChange | +| Passed (unaffected, reflective binding) | BreadcrumbSelectorCoordinatorTests | BoundaryAndInvalidOperations_AreDeterministicNoOps | +| Passed (unaffected, reflective binding) | BreadcrumbSelectorCoordinatorTests | OpenDown_ChangesPendingOnlyThenEnterCommitsAndCloses | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-pending-style-fail-before.2026-10-25T23-55.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-pending-style-fail-before.2026-10-25T23-55.md new file mode 100644 index 000000000..190416ee8 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-pending-style-fail-before.2026-10-25T23-55.md @@ -0,0 +1,31 @@ +# QFC Pending Style: Fail Before (Issue #1007, B-1, P1-T2) [expect-fail] + +Timestamp: 2026-10-25T23-55 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true, then vstest.console.exe QuickFiler.Test.dll UtilitiesCS.Test.dll /InIsolation /Settings:scripts/vscode/TaskMaster.cli.runsettings "/TestCaseFilter:FullyQualifiedName~FolderBreadcrumbKeysIssue1007Tests" (results under SCRATCH) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: The ANALYZE build reported `Build succeeded.`, `0 Warning(s)`, `0 Error(s)`. The scoped run matched 9 tests: 8 passed and 1 failed. The failure is the missing `.sub.active` rule, as expected before the fix. + +## Trx-derived summary + +| Figure | Value | +|---|---| +| Total | 9 | +| Passed | 8 | +| Failed | 1 | + +| Test | Outcome | +|---|---| +| SelectorKeys_MapHomeEndPageUpPageDown | Passed | +| CtrlArrowLeft_PostsParentSelectorKey | Passed | +| PageKeys_CarryPageSizeFromFullyVisibleCount | Passed | +| OnArrow_AlwaysPostsArrowKey | Passed | +| MakeSubfolders_MarksPendingNodeActive | Passed | +| NonLeafSegmentClick_OpenList_StopsPropagationAndPostsSelectorHighlight | Passed | +| SegmentDoubleClick_PostsSelectorSegmentDoubleClick | Passed | +| MakeSubfolders_LoadedChildWithoutChildren_RendersNoToggle | Passed | +| Stylesheet_StylesPendingDescendantLikeSelectedSubfolder | Failed | + +Failure message: `Expected declarations not to be because the QFC stylesheet must style the pending descendant (.sub.active).` + +The failure comes from the `.sub.active` lookup. The `.sub.selected` positive-control lookup and its two variable assertions ran first and passed, so the failure isolates the missing rule. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-pending-style-pass-after.2026-10-25T23-55.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-pending-style-pass-after.2026-10-25T23-55.md new file mode 100644 index 000000000..0c8aa0009 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/qfc-pending-style-pass-after.2026-10-25T23-55.md @@ -0,0 +1,22 @@ +# QFC Pending Style: Pass After (Issue #1007, B-1, P2-T2) + +Timestamp: 2026-10-25T23-55 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true, then vstest.console.exe QuickFiler.Test.dll UtilitiesCS.Test.dll /InIsolation /Settings:scripts/vscode/TaskMaster.cli.runsettings "/TestCaseFilter:FullyQualifiedName~FolderBreadcrumbKeysIssue1007Tests|FullyQualifiedName~FolderBreadcrumbAssetContractTests" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: The ANALYZE full `/t:Rebuild` reported `Build succeeded.`, `0 Warning(s)`, `0 Error(s)`, which re-embeds `FolderBreadcrumb.html`. Test Run Successful: 24 total, 24 passed, 0 failed. `Stylesheet_StylesPendingDescendantLikeSelectedSubfolder` passed. + +## Trx-derived summary + +| Figure | Value | +|---|---| +| Total | 24 | +| Passed | 24 | +| Failed | 0 | + +| Test | Outcome | +|---|---| +| Stylesheet_StylesPendingDescendantLikeSelectedSubfolder | Passed | +| The other 8 `FolderBreadcrumbKeysIssue1007Tests` tests | Passed | +| All 15 `FolderBreadcrumbAssetContractTests` tests | Passed | + +The disposition of the existing tests is unchanged: none asserts the edited rule (plan D6), and every one passed. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/visible-order-fail-before.2026-10-23T13-56.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/visible-order-fail-before.2026-10-23T13-56.md new file mode 100644 index 000000000..28c475ac2 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/visible-order-fail-before.2026-10-23T13-56.md @@ -0,0 +1,23 @@ +# Visible-Order Fail-Before (Issue #1007, P4-T2) + +Timestamp: 2026-10-23T13-56 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:FullyQualifiedName~BreadcrumbVisibleOrderTests (results under SCRATCH) +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: build `0 Error(s)`. Tests: total 13, executed 13, passed 0, failed 13. Every G2 test failed against the Phase 1 declaration-only seams. Summary derived from the trx. + +| Outcome | Test | Failure message (from the trx) | +|---|---|---| +| Failed | Step_DownFromExpandedRow_VisitsDescendantsInPreOrder | NotImplementedException: The method or operation is not implemented. | +| Failed | Step_SkipsChildrenOfCollapsedNode | NotImplementedException: The method or operation is not implemented. | +| Failed | Step_FromBoundaryOutward_ReturnsMinusOne | NotImplementedException: The method or operation is not implemented. | +| Failed | Step_PageDelta_ClampsToFirstAndLast | NotImplementedException: The method or operation is not implemented. | +| Failed | FirstAndLast_ReturnBoundaryIndices | NotImplementedException: The method or operation is not implemented. | +| Failed | FirstAndLast_EmptyOrder_ReturnMinusOne | NotImplementedException: The method or operation is not implemented. | +| Failed | IndexOf_UnknownEntry_ReturnsMinusOne | NotImplementedException: The method or operation is not implemented. | +| Failed | IndexOf_NullOrder_Throws | Expected a System.ArgumentNullException to be thrown, but found System.NotImplementedException. | +| Failed | ParentOf_NodeEntry_ReturnsParentIndex | NotImplementedException: The method or operation is not implemented. | +| Failed | AppendVisiblePositions_ExpandedRow_AddsRowThenDescendantsInPreOrder | NotImplementedException: The method or operation is not implemented. | +| Failed | AppendVisiblePositions_BannerRow_AddsNothing | NotImplementedException: The method or operation is not implemented. | +| Failed | AppendVisiblePositions_ZeroChildrenNode_ReportsNoChildren | NotImplementedException: The method or operation is not implemented. | +| Failed | VisiblePositions_QfcModel_SkipsNonSelectableRowsAndCollapsedChildren | NotImplementedException: The method or operation is not implemented. | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/visible-order-pass-after.2026-10-23T13-57.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/visible-order-pass-after.2026-10-23T13-57.md new file mode 100644 index 000000000..4bcf00ee3 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/regression-testing/visible-order-pass-after.2026-10-23T13-57.md @@ -0,0 +1,22 @@ +# Visible-Order Pass-After (Issue #1007, P5-T4) + +Timestamp: 2026-10-23T13-57 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true; then vstest.console.exe UtilitiesCS.Test.dll QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~Breadcrumb|FullyQualifiedName~OutlookFolderHierarchyProvider" (results under SCRATCH) +EXIT_CODE: 0 +Output Summary: build `0 Error(s)`, `0 Warning(s)`. BREADCRUMB-FILTER run: total 970, executed 970, passed 970, failed 0. Every G2 test passed; the unchanged `BreadcrumbRowDescendantsIssue1002Tests` class passed within the same run. + +| Outcome | Test | +|---|---| +| Passed | Step_DownFromExpandedRow_VisitsDescendantsInPreOrder | +| Passed | Step_SkipsChildrenOfCollapsedNode | +| Passed | Step_FromBoundaryOutward_ReturnsMinusOne | +| Passed | Step_PageDelta_ClampsToFirstAndLast | +| Passed | FirstAndLast_ReturnBoundaryIndices | +| Passed | FirstAndLast_EmptyOrder_ReturnMinusOne | +| Passed | IndexOf_UnknownEntry_ReturnsMinusOne | +| Passed | IndexOf_NullOrder_Throws | +| Passed | ParentOf_NodeEntry_ReturnsParentIndex | +| Passed | AppendVisiblePositions_ExpandedRow_AddsRowThenDescendantsInPreOrder | +| Passed | AppendVisiblePositions_BannerRow_AddsNothing | +| Passed | AppendVisiblePositions_ZeroChildrenNode_ReportsNoChildren | +| Passed | VisiblePositions_QfcModel_SkipsNonSelectableRowsAndCollapsedChildren | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-coverage.2026-10-25T23-53.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-coverage.2026-10-25T23-53.md new file mode 100644 index 000000000..b544ede9a --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-coverage.2026-10-25T23-53.md @@ -0,0 +1,48 @@ +# Baseline Coverage (Issue #1007, remediation cycle 1, P0-T4) + +Timestamp: 2026-10-25T23-53 +Command: scripts/vscode/Invoke-MSTestWithCoverage.ps1 (TEST-COVERAGE, run in the item worktree against the P0-T3 rebuild) +EXIT_CODE: 0 +Output Summary: Test Run Successful. Total 7756, passed 7756, failed 0. First-party coverage: lines 58411/67553 (86.47%), branches 14433/17860 (80.81%). BASELINE-FAILURES: none. Every N1-LINES body line has 0 hits. + +## Counts (derived from the trx `ResultSummary` counters) + +| Figure | Value | +|---|---| +| Total | 7756 | +| Executed | 7756 | +| Passed | 7756 | +| Failed | 0 | +| Not executed | 0 | + +BASELINE-FAILURES: none. No test failed in this run. + +## Repository coverage (one-line first-party summary) + +`First-party coverage: lines 58411/67553 (86.47%), branches 14433/17860 (80.81%)` + +- Line: 86.47 percent (floor 80). +- Branch: 80.81 percent (floor 75). + +## Per-file coverage (post-processed Cobertura, de-duplicated per file and line number) + +| File | Line | Branch | +|---|---|---| +| QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs | 122/133 (91.73%) | 42/52 (80.77%) | +| UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs | 143/152 (94.08%) | 60/68 (88.24%) | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs | 131/134 (97.76%) | 40/42 (95.24%) | +| QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs | 243/249 (97.59%) | 69/86 (80.23%) | + +## N1-LINES hits (instrumented lines only) + +The executor located each N1-LINES block by reading the member body in the current source. A condition line is the `if` statement; the body lines are those inside its block. + +| Member | Instrumented lines in the block (condition first) | Hits | +|---|---|---| +| `BreadcrumbBridgeRouter.HandleLeafToggleAsync`, the `row.IsCollapsed && !row.IsAnchoredCollapse` block | condition; body lines 1 to 6 | 1; 0, 0, 0, 0, 0, 0 | +| `FolderBreadcrumbBridgeRouter.ToggleAsync`, the `CollapsedAfterIndex != ActiveSegmentIndex` block | condition (4 lines); body lines 1 to 3 | 1, 1, 1, 1; 0, 0, 0 | +| `BreadcrumbStateModel.Expand`, the matching block | condition (4 lines); body lines 1 to 3 | 1, 1, 1, 1; 0, 0, 0 | +| `BreadcrumbBridgeCoordinator.PublishRouterOutputs`, `UnhandledArrowMessage` arm | condition; body lines 1 to 3 | 1; 0, 0, 0 | +| `BreadcrumbBridgeCoordinator.RaiseSyntheticArrowKey`, `UnhandledArrowMessage` arm | condition; body lines 1 to 3 | 1; 0, 0, 0 | + +The Cobertura copy for P5-T5 is kept in SCRATCH as `issue-1007-r1-baseline-cobertura.xml` and is not committed. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-file-sizes.2026-10-25T23-53.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-file-sizes.2026-10-25T23-53.md new file mode 100644 index 000000000..38ed7f069 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-file-sizes.2026-10-25T23-53.md @@ -0,0 +1,17 @@ +# Baseline File Sizes (Issue #1007, remediation cycle 1, P0-T5) + +Timestamp: 2026-10-25T23-53 +Command: physical line count per file, `@(Get-Content -LiteralPath ).Count` (not `Measure-Object -Line`), from a SCRATCH helper +EXIT_CODE: 0 +Output Summary: Six existing WRITE-SET C# files were counted. Every count is at most 500, and the largest is `FolderBreadcrumbBridgeRouterIssue1007Tests.cs` at 499 lines, which is not edited. The two new files do not yet exist. + +| File | Physical lines | +|---|---| +| QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs | 158 | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs | 185 | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs | 444 | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs (P3-T5 modifier edit only) | 477 | +| UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs | 293 | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.cs (not edited) | 499 | +| QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.Unhandled.cs | absent (created by P3-T6) | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.Unanchored.cs | absent (created by P3-T2) | diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-format.2026-10-25T23-51.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-format.2026-10-25T23-51.md new file mode 100644 index 000000000..d45f04b67 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-format.2026-10-25T23-51.md @@ -0,0 +1,6 @@ +# Baseline Format Check (Issue #1007, remediation cycle 1, P0-T2) + +Timestamp: 2026-10-25T23-51 +Command: dotnet tool run csharpier check . +EXIT_CODE: 0 +Output Summary: `Checked 1698 files in 4559ms.` No file drifts from the formatter. The command is read-only and changed no file. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-typecheck.2026-10-25T23-51.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-typecheck.2026-10-25T23-51.md new file mode 100644 index 000000000..728ae4792 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/baseline-typecheck.2026-10-25T23-51.md @@ -0,0 +1,6 @@ +# Baseline Type Check (Issue #1007, remediation cycle 1, P0-T3) + +Timestamp: 2026-10-25T23-51 +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true (run with /v:minimal /clp:Summary /nologo for a readable log; full log kept under SCRATCH) +EXIT_CODE: 0 +Output Summary: `Build succeeded.`, `0 Warning(s)`, `0 Error(s)`, elapsed 00:00:14.19. Every project was rebuilt, including `QuickFiler.Test` and `UtilitiesCS.Test`, whose assemblies P0-T4 measures. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/phase0-instructions-read.2026-10-25T23-50.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/phase0-instructions-read.2026-10-25T23-50.md new file mode 100644 index 000000000..56d88c5e8 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/remediation-baseline/phase0-instructions-read.2026-10-25T23-50.md @@ -0,0 +1,31 @@ +# Phase 0 Instructions Read (Issue #1007, remediation cycle 1, P0-T1) + +Timestamp: 2026-10-25T23-50 +Policy Order: CLAUDE.md -> .claude/rules/general-code-change.md -> .claude/rules/general-unit-test.md -> .claude/rules/csharp.md (policy-compliance-order), then the supporting rules, skills, and remediation inputs listed below. +Branch: bug/efc-arrow-keys-skip-subfolders-1007 +Command: git status --porcelain --untracked-files=all +EXIT_CODE: 0 +Output Summary: Every file was read. The working tree holds only two untracked files under `/`: this plan and the preflight-fix record. + +## Files read + +1. `CLAUDE.md` +2. `.claude/rules/general-code-change.md` +3. `.claude/rules/general-unit-test.md` +4. `.claude/rules/csharp.md` +5. `.claude/rules/plan-acceptance-gates.md` +6. `.claude/skills/execution-environment/SKILL.md` +7. `.claude/skills/evidence-and-timestamp-conventions/SKILL.md` +8. `.claude/skills/acceptance-criteria-tracking/SKILL.md` +9. `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/remediation-inputs.2026-10-25T23-25.md` +10. `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/code-review.2026-10-25T23-25.md` +11. `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/spec.md` (`## Acceptance Criteria`) + +## Porcelain capture + +``` +?? docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/evidence/other/preflight-fixes.2026-10-25T23-50.md +?? docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/remediation-plan.2026-10-25T23-50.md +``` + +No path outside `/` is listed. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/feature-audit.2026-10-25T23-25.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/feature-audit.2026-10-25T23-25.md new file mode 100644 index 000000000..d381c7791 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/feature-audit.2026-10-25T23-25.md @@ -0,0 +1,70 @@ +# Feature Audit: EFC and QFC arrow keys skip expanded subfolders (Issue #1007) + +- Review timestamp: 2026-10-25T23-25 (first review of this branch) +- Reviewer: feature-review agent + +## Scope and Baseline + +- Branch: `bug/efc-arrow-keys-skip-subfolders-1007`, head `8bdd970ee` (merge of `origin/main`; the #1007 execution commit is `8082ef3b2`). +- Resolved base branch: `main`, merge base `3b9a1ebdd` (the current `origin/main`). The audit diff is `git diff 3b9a1ebdd..HEAD` (116 files); the merge brought in #1005 only, which the merge base already contains, so no #1005 file is in the diff. +- Work mode: `full-bug` (`- Work Mode: full-bug` in `issue.md`); AC source: `spec.md` `## Acceptance Criteria` only (AC1 to AC14, in the order AC1 to AC11, AC14, AC12, AC13). `issue.md` carries a mirror of the same criteria. Decision record: `grill-transcript.md` (D-Q1 to D-Q29) and the spec's S1 to S10. +- PR context: `artifacts/pr_context.summary.txt` and `.appendix.txt` are absent from this worktree and the PR-context MCP route is not available to this agent; scope was derived from `git diff`. +- Evidence basis: executor projections under `evidence/{baseline,regression-testing,qa-gates,other}/`, the canonical coverage artifact `artifacts/csharp/coverage.xml`, the post-processed Cobertura documents of the baseline and final runs (recomputed by the reviewer), a reviewer re-run of the node suites, and reviewer reads of source and diff. +- Manual Outlook verification (spec Test Strategy) is a human step recorded by the orchestrator; it is not evaluated here. + +## Acceptance Criteria Inventory + +Source: `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/spec.md`, `## Acceptance Criteria`. Fourteen checkbox items, all checked `[x]` when this review began. + +| AC | Subject | +|---|---| +| AC1 | Up and Down visit every visible row in tree order | +| AC2 | No wrap; EFC Up on the first row focuses the search box | +| AC3 | EFC arrow selects as a click does; Enter files to it | +| AC4 | QFC open-list pending model (arrows move only the pending highlight; Enter, Escape, Left commit-and-close) | +| AC5 | Right expands or moves to the first child; shortened row lists the segment's children | +| AC6 | Right on a leaf (EFC no-op; QFC open: close, keep, pop-out) | +| AC7 | Left collapses only; #440 walk and Left segment hiding removed; nearest visible ancestor | +| AC8 | Ctrl+Left parent selection, Escape restores shape and value, Ctrl+Right is Right | +| AC9 | Segment double-click shortens and commits; `[+]` lists the segment's children; QFC non-leaf single click | +| AC10 | QFC closed list keys | +| AC11 | Home, End, PageUp, PageDown; EFC no-selection start | +| AC14 | No `[+]` without subfolders | +| AC12 | Regression tests first; rewritten #440 tests record the reason | +| AC13 | Toolchain, coverage and file size | + +## Acceptance Criteria Evaluation + +| AC | Verdict | Evidence | +|---|---|---| +| AC1 | PASS | `BreadcrumbVisibleOrder.AppendNodes` adds a node's children only while it is expanded. EFC: `ArrowDown_FromExpandedRow_SelectsFirstVisibleDescendant`, `ArrowDown_SkipsChildrenOfCollapsedNode`, `ArrowUp_FromRowBelowExpandedBlock_SelectsLastVisibleDescendant`; QFC: `SelectorKeyDown_Open_MovesPendingThroughVisibleDescendants`; pure helper: `BreadcrumbVisibleOrderTests`. Fail-before and pass-after in `visible-order-*`, `efc-vertical-*`, `qfc-open-*`. The traversal order holds in both viewers; the QFC rendering of a pending descendant is evaluated under AC4. | +| AC2 | PASS | `BreadcrumbVisibleOrder.Step` clamps and returns -1 at either end; EFC `HandleVerticalKey` posts `BreadcrumbFocusSearchMessage` and raises `FocusSearchRequested` only for Up with no previous entry. Tests `ArrowDown_OnLastVisibleEntry_PostsNothing`, `ArrowUp_OnFirstEntry_RaisesFocusSearchRequested`, `SelectorKeyDown_Open_OnLastEntry_KeepsPending`. | +| AC3 | PASS | `CommitPosition` commits a node through `SelectHierarchyPath(row, path, nodeId)` and a row entry through `SelectRowEntry`, the click paths. `ArrowDown_OntoDescendant_CommitsArchiveRelativePathAndRaisesChanged`, `ArrowDown_OntoDescendant_ThenRowConfirmed_RaisesConfirmationForDescendant`. | +| AC4 | PARTIAL | Behaviour: open-list moves change only `PendingIdentity` and `PendingNode` (`OpenMove_DoesNotChangeCommittedValue`), Enter commits the pending node (`CommitPending_WithPendingNode_SelectsDescendant`), Escape restores (`Cancel_RestoresOriginalAndClearsPendingNode`), Left on a non-expanded entry commits and closes (`OpenLeft_OnNonExpandedEntry_CommitsAndCloses`). Gap (B-1): when the pending entry is a descendant that is not the committed subfolder, `FolderBreadcrumb.js` renders it as `class="sub active"` and removes the row's `active` class, and `FolderBreadcrumb.html` has no `.sub.active` rule, so the pending highlight is not visible. The criterion's "pending highlight" is not observable to the user in that state. | +| AC5 | PASS | `HandleRightAsync` (EFC) and `PendingArrowAsync` (QFC) follow the decision table. `ArrowRight_OnUnexpandedRowWithChildren_Expands`, `ArrowRight_OnExpandedRow_SelectsFirstDescendantWithNodeId`, `ArrowRight_OnShortenedRow_ListsSegmentChildrenWithSingleQuery`, `ArrowRight_ReentrantDuringFetch_EndsExpandedWithOneQuery` (held on a `TaskCompletionSource`; one provider query, row ends expanded), `OpenRight_OnExpandedPendingEntry_PendsFirstChild`. | +| AC6 | PASS | EFC Right on a position without children returns. QFC: `ClassifyPendingArrow` returns `CancelAndPopOut`; the coordinator calls `CancelSelector()` and then raises `UnhandledArrow(Right)`. `ArrowRight_OnLeaf_PostsNothing`, `OpenRight_OnLeaf_CancelsThenRaisesUnhandledArrowRight` (fail-before in `qfc-open-fail-before.2026-10-25T22-08.md`). | +| AC7 | PASS | `BreadcrumbRow.LeftArrow` and `BreadcrumbStateModel.LeftArrow` only collapse; `TryRightTreeTransition` and the EFC walk are deleted. `CollapseNode` and `CollapseRowLeaf` re-commit only when the collapse hides the selection; `ReconcilePendingNode` moves a hidden pending node to its deepest visible ancestor. `ArrowLeft_OnExpandedNode_CollapsesOnlyThatNode`, `ArrowLeft_OnNonExpandedRow_IsNoOp`, `LeftArrow_NeverHidesTrailingSegment`, `CollapseHidingSelection_RecommitsNearestVisibleAncestor`, `CollapseHidingPending_MovesPendingToNearestVisibleAncestor`. | +| AC8 | PASS | EFC `HandleParentLeft` (node: commit parent keeping expansion; row: `ShortenAt(active - 1)`; index 0: no-op). QFC `MoveToParent` (open: pending only; closed: `CommitParent`) and `RestoreOpenShape` on Cancel. `BridgeJs` maps Ctrl+ArrowLeft to `ParentLeft` and Ctrl+ArrowRight to `Right`; `FolderBreadcrumb.js` posts `parent`. Tests named in `ac-reconciliation.2026-10-25T23-14.md` row AC8. | +| AC9 | PASS | EFC `ShortenAt` is atomic; `[+]` on an anchored collapse expands the active segment. QFC `DoubleClickSegment` activates, shortens, commits and closes; `SelectedRowValue` files to the active segment (S9). The non-leaf single click posts `selectorHighlight` and stops propagation; the row-level pending highlight uses the styled `.row.active`. Tests `SegmentDoubleClick_ActivatesCommitsAndShortensAtomically`, `LeafExpandToggle_OnShortenedRow_ListsSegmentChildren`, `ShortenedRowExpanded_NextRight_SelectsFirstChild`, `SelectorSegmentDoubleClick_CommitsSegmentFolderAndCloses`, `GetSelectedFolder_ShortenedRow_ReturnsActiveSegmentValue`, node "non-leaf segment click". | +| AC10 | PASS | `TryDispatchOpenArrowAsync`: closed Right opens the selector, closed Left is consumed; `BeginOpenTree` pends the committed subfolder when visible (D-Q29); closed `Move` is unchanged; `CommitSelectorBoundaryRow` commits the first and last top-level row; `MovePage` returns false when closed. Seven G6 tests (`qfc-closed-fail-before` and `-pass-after`). | +| AC11 | PASS | EFC `HandleVerticalKey` and `HandleNoSelectionKey`; QFC `MoveToBoundary` and `MovePage`; `TryPageDelta` rejects a missing or non-positive page size; both page scripts measure fully visible rows with a minimum of 1. `Home_SelectsFirstEntryWithoutFocusSearch`, `End_SelectsLastVisibleDescendant`, `PageDown_MovesByPageSizeAndClamps`, `PageKey_InvalidPageSize_IsLoggedNoOp`, `NoSelection_DownAndHomeSelectFirst_UpAndEndSelectLast`, node `fullyVisibleCount`. | +| AC14 | PASS | `MapNode` derives `HasChildren` from `snapshot.GetChildren(node.Key).Count > 0` for both provider entry points; defences in `BreadcrumbHtmlRenderer` (leaf affordance and descendant toggle), `BreadcrumbRenderProjection` (leaf and descendant) and `makeSubfolders`. `MapNode_UnresolvableChildKey_HasChildrenFalse`, `GetImmediateSubfolders_ChildWithOnlyUnresolvableKeys_HasChildrenFalse`, `GetAncestorChain_ResolvableChildKey_HasChildrenTrue`, `BreadcrumbEmptyExpanderIssue1007Tests` (5), C# string-contract test `MakeSubfolders_LoadedChildWithoutChildren_RendersNoToggle` and its node twin. Fail-before `ac14-fail-before.2026-10-23T13-51.md`. Observation N-5 (a collapse after a zero-children fetch re-shows `+` in two shortened or QFC states) is defence-in-depth only and does not contradict the criterion while the source fix holds. | +| AC12 | PASS | Eight fail-before and eight pass-after projections. The single exception, `fail-before-exception.2026-10-25T22-23.md` for `UnhandledRightArrow_RaisesUnhandledArrowRight`, is valid: the update adds only an `OpenSelector()` precondition to a behaviour that holds in every code state, and the AC6 behaviour has its own failing evidence. Rewritten #440 and `[+]` restore tests match the spec disposition table and each summary records #1007 and the governing AC (names are code-review N-2). | +| AC13 | PASS | One clean pass in CLAUDE.md order (`final-toolchain-format.2026-10-25T23-07.md`, `-analyzers.2026-10-25T23-07.md`, `-typecheck.2026-10-25T23-07.md`, `-tests.2026-10-25T23-09.md`: 7734 of 7734). Node suites 90 of 90 in five orders, re-run by the reviewer. Repository 86.40% line and 80.73% branch; new files 91.84% to 100% line; every changed line at or above its replaced-line baseline (reviewer recomputation). Largest changed code file 500 lines. | + +## Summary + +Thirteen of fourteen acceptance criteria evaluate PASS; AC4 evaluates PARTIAL. The review returns **REMEDIATION_REQUIRED** with one blocking finding, B-1 (autonomous): the QFC page has no stylesheet rule for a pending descendant (`.sub.active`), so in the open QFC list a pending highlight on a subfolder other than the committed one is not visible. The keyboard state machine behind it is correct and tested. The remedy is a one-rule stylesheet change in `QuickFiler/Resources/FolderBreadcrumb.html` with a C# string-contract assertion, followed by a full toolchain pass. + +The removed #440 behaviour is removed consistently in both viewers, and each test that pinned it is rewritten with the reason recorded. The AC14 fix sits at the single place where the has-children flag is derived. The fail-before exception dossier is valid. + +### Acceptance Criteria Status +- Source: `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/spec.md` +- Total AC items: 14 +- Checked off (delivered): 13 +- Remaining (unchecked): 1 +- Items remaining: AC4 (QFC open-list pending model): with the QFC folder list open, arrows move only the pending highlight. Enter commits, Escape restores the original choice, and Left on a highlighted item that is not expanded commits it and closes the list. (Q7, Q15) + +## Acceptance Criteria Check-off + +All fourteen items were `- [x]` in `spec.md` when this review began. Thirteen evaluate PASS and stay checked; no box was newly checked. AC4 evaluates PARTIAL, so its box was changed from `- [x]` to `- [ ]` in `spec.md` and in the `issue.md` mirror, with the criterion text unchanged, per the acceptance-criteria-tracking rule that PARTIAL items stay unchecked. The remediation executor checks AC4 off again once B-1 is fixed and verified. diff --git a/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/feature-audit.2026-10-26T00-14.md b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/feature-audit.2026-10-26T00-14.md new file mode 100644 index 000000000..b0d303926 --- /dev/null +++ b/docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/feature-audit.2026-10-26T00-14.md @@ -0,0 +1,81 @@ +# Feature Audit: EFC and QFC arrow keys skip expanded subfolders (Issue #1007) + +- Review timestamp: 2026-10-26T00-14 (re-audit after remediation cycle 1) +- Previous review: `feature-audit.2026-10-25T23-25.md` (13 PASS, AC4 PARTIAL) +- Reviewer: feature-review agent + +## Scope and Baseline + +- Branch: `bug/efc-arrow-keys-skip-subfolders-1007`, head `e804f5d6b` (remediation cycle 1 commit). +- Resolved base branch: `main`, merge base `3b9a1ebdd` (equal to the current `origin/main`, recomputed with `git merge-base HEAD origin/main`). The audit diff is `git diff 3b9a1ebdd..HEAD` (149 files), the full branch, not the cycle commit alone. +- Work mode: `full-bug` (`- Work Mode: full-bug` in `issue.md`); AC source: `spec.md` `## Acceptance Criteria` only (AC1 to AC14, listed in the order AC1 to AC11, AC14, AC12, AC13). `issue.md` carries a mirror. Decision record: `grill-transcript.md` (D-Q1 to D-Q29) and the spec's S1 to S10. +- Cycle inputs: `remediation-inputs.2026-10-25T23-25.md` and `remediation-plan.2026-10-25T23-50.md` (every task checked); cycle self-review `evidence/qa-gates/self-review.2026-10-26T00-09.md` (36 PASS, 0 FAIL, 4 N/A). +- PR context: `artifacts/pr_context.summary.txt` and `.appendix.txt` are absent from this worktree; scope was derived from `git diff`. +- Evidence basis: executor projections under `evidence/`, the canonical coverage artifact `artifacts/csharp/coverage.xml` (package counters summed by the reviewer), the post-processed Cobertura document of the same run (per-file figures recomputed by the reviewer), a reviewer re-run of the four node suites (90 of 90) and of `csharpier check` over the changed directories (exit 0), and reviewer reads of the cycle diff and the stylesheet. +- Manual Outlook verification (spec Test Strategy) is a human step recorded by the orchestrator; it is not evaluated here. + +## Acceptance Criteria Inventory + +Source: `docs/features/active/2026-10-22-efc-arrow-keys-skip-subfolders-1007/spec.md`, `## Acceptance Criteria`. Fourteen checkbox items, all checked `[x]` when this review began (AC4 re-checked by the cycle executor in `e804f5d6b`, criterion text unchanged). + +| AC | Subject | +|---|---| +| AC1 | Up and Down visit every visible row in tree order | +| AC2 | No wrap; EFC Up on the first row focuses the search box | +| AC3 | EFC arrow selects as a click does; Enter files to it | +| AC4 | QFC open-list pending model (arrows move only the pending highlight; Enter, Escape, Left commit-and-close) | +| AC5 | Right expands or moves to the first child; shortened row lists the segment's children | +| AC6 | Right on a leaf (EFC no-op; QFC open: close, keep, pop-out) | +| AC7 | Left collapses only; #440 walk and Left segment hiding removed; nearest visible ancestor | +| AC8 | Ctrl+Left parent selection, Escape restores shape and value, Ctrl+Right is Right | +| AC9 | Segment double-click shortens and commits; `[+]` lists the segment's children; QFC non-leaf single click | +| AC10 | QFC closed list keys | +| AC11 | Home, End, PageUp, PageDown; EFC no-selection start | +| AC14 | No `[+]` without subfolders | +| AC12 | Regression tests first; rewritten #440 tests record the reason | +| AC13 | Toolchain, coverage and file size | + +## Acceptance Criteria Evaluation + +| AC | Verdict | Evidence | +|---|---|---| +| AC1 | PASS | Unchanged since the first review (`BreadcrumbVisibleOrder`, EFC and QFC traversal tests; `visible-order-*`, `efc-vertical-*`, `qfc-open-*` fail-before and pass-after). With B-1 fixed, a pending descendant reached by Down in the open QFC list is now visibly highlighted. All tests pass in the final run (7761 of 7761). | +| AC2 | PASS | Unchanged since the first review (`Step` clamps; `ArrowDown_OnLastVisibleEntry_PostsNothing`, `ArrowUp_OnFirstEntry_RaisesFocusSearchRequested`, `SelectorKeyDown_Open_OnLastEntry_KeepsPending`). | +| AC3 | PASS | Unchanged since the first review (`CommitPosition` through the click paths; `ArrowDown_OntoDescendant_CommitsArchiveRelativePathAndRaisesChanged`). | +| AC4 | PASS | Behaviour: `OpenMove_DoesNotChangeCommittedValue`, `CommitPending_WithPendingNode_SelectsDescendant`, `Cancel_RestoresOriginalAndClearsPendingNode`, `OpenLeft_OnNonExpandedEntry_CommitsAndCloses` (Passed in `final-toolchain-tests.2026-10-26T00-03.md`). Visibility (B-1): `FolderBreadcrumb.html` line 158 styles `.sub.active` with `var(--bc-sel-back)` and `var(--bc-sel-fore)`, which are defined for the light, explicit dark and system dark themes; `makeRow` applies no `selected` class in the expanded view and drops `active` while the pending node owns it, so exactly one list element carries the highlight. `Stylesheet_StylesPendingDescendantLikeSelectedSubfolder` failed before the fix (`qfc-pending-style-fail-before.2026-10-25T23-55.md`) and passes after (`qfc-pending-style-pass-after.2026-10-25T23-55.md`, `qfc-pending-style.2026-10-26T00-05.md`); reviewer read of the stylesheet confirms one `.sub.active` match inside ``, and finds the rule whose selector list contains a given selector. As a positive control it first finds the `.sub.selected` rule and asserts both variables in its declaration block; it then finds the `.sub.active` rule, with the reason text `the QFC stylesheet must style the pending descendant (.sub.active)`, and asserts that its block contains `var(--bc-sel-back)` and `var(--bc-sel-fore)`. Restricting the search to the style block keeps the page script's `"sub active"` class string from satisfying the test. +- D3 N-1 reachability (no branch is deleted): the EFC branch is reachable from inbound messages: a `segmentDoubleClick` at segment 1 followed by `segmentActivate` at segment 0 leaves `CollapsedAfterIndex` at 1 and `ActiveSegmentIndex` at 0 (the existing `ArrowRight_OnRowCollapsedAwayFromActiveSegment_RestoresChain` uses this sequence for Right). The two QFC branches are reachable through the public `BreadcrumbStateRow.CollapseAfter` contract, which accepts any non-leaf index whatever the active segment (as the existing `CollapseAfter_NonLeafSegment_HidesDownstreamAndClosesLeafExpansion` relies on); they keep the Right and `[+]` behavior of that public state defined. Deleting them would edit the keyboard state machine, which this cycle must not change. The coordinator arm is reachable when an inbound `unhandledArrow` message is classified `Route` (open list, pending row expanded, Left): the router echoes the message as its output. +- D4 Placement: new QuickFiler.Test code compiles at C# 7.3, so it carries no `#nullable enable` and no C# 8 pattern syntax; new UtilitiesCS.Test files carry `#nullable enable`. `BreadcrumbBridgeCoordinatorIssue1007Tests.cs` is at 477 lines, so it becomes `partial` and the new coordinator test goes in a new partial file that reuses its private `Harness`. `FolderBreadcrumbBridgeRouterIssue1007Tests.cs` is at 499 lines and is already `partial`, so the new QFC router test goes in a new partial file that reuses its helpers. Every new file gets an explicit `` in its legacy project file. +- D5 N-2 renames (summaries and bodies unchanged): + +| File | Old name | New name | +|---|---|---| +| `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs` | `HandleArrowKey_LeftOnMultiSegmentRow_ActivatesParentSegment` | `HandleArrowKey_LeftOnNonExpandedMultiSegmentRow_IsNoOpAndKeepsLeafActive` | +| same | `HandleArrowKey_RepeatedLeft_WalksToRootThenFallsThroughToExistingBehavior` | `HandleArrowKey_RepeatedLeftOnNonExpandedRow_PostsNoRenderAndKeepsSelection` | +| same | `HandleArrowKey_RightAfterExpansion_DescendsByChildActivation` | `HandleArrowKey_RightOnExpandedRow_SelectsFirstDescendantUnlessNoChildren` | +| `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs` | `RightArrow_OnCollapsedRow_ReExpandsBeforeLeafExpansion` | `RightArrow_OnRowShortenedAtActiveSegment_ExpandsAndStaysShortened` | +| same | `LeftArrow_RepeatedOnThreeSegmentChain_WalksToRootThenReportsUnhandled` | `LeftArrow_RepeatedOnNonExpandedChain_IsUnhandledAndKeepsLeafActive` | +| same | `LeftArrow_WalkFromAnOpenLeafExpansion_ClearsTheExpansionAndStillReachesTheRoot` | `LeftArrow_FromOpenLeafExpansion_CollapsesThenIsUnhandled` | + +- D6 Existing tests affected by the B-1 edit: a search of `QuickFiler.Test/`, `UtilitiesCS.Test/`, and `tests/quickfiler-breadcrumb/` for `.sub.selected`, `bc-sel`, and `/evidence//..md`, stamped with the UTC write time per `.claude/skills/evidence-and-timestamp-conventions/SKILL.md`; a consumer selects the lexicographically greatest `.*.md`. +- `SCRATCH` is `/issue-1007/`. Every scratch file, trx, raw coverage copy, and helper script lives under it with a name starting `issue-1007-`. Artifacts record the token `SCRATCH`, never a host path, host name, or account name. +- Tool routes, launchers, and command-text restrictions are owned by `.claude/skills/execution-environment/SKILL.md` and are not restated. FORMAT, ANALYZE, TYPECHECK, and TEST-COVERAGE are the four `CLAUDE.md` C# toolchain commands, run exactly as written (both builds use `/t:Rebuild`; TYPECHECK never adds `/p:Nullable=enable`; TEST-COVERAGE is `scripts/vscode/Invoke-MSTestWithCoverage.ps1`). +- SCOPED-TEST is a `vstest.console.exe` run over the built `QuickFiler.Test` and `UtilitiesCS.Test` assemblies with `/InIsolation`, `/Settings:scripts/vscode/TaskMaster.cli.runsettings` (parallel, `Workers` 0, `ClassLevel`), the `/TestCaseFilter` the task names, and results under `SCRATCH`; discovery excludes any path containing the `.claude` worktree segment. A filter that matches zero tests is a failure. Evidence records a summary derived from the trx (counts plus one outcome row per named test), never the trx. +- NODE-RUN is the node test runner over the four `tests/quickfiler-breadcrumb/` suites (`issue-997-common`, `issue-997-recursive`, `issue-1002-nesting`, `issue-1007-keys`) in one process with test concurrency 1, so file order is execution order. +- WRITE-SET is: `QuickFiler/Resources/FolderBreadcrumb.html`; `QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs`; `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs`; `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs`; `QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs`; the new `QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.Unhandled.cs`; `QuickFiler.Test/QuickFiler.Test.csproj`; `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs`; the new `UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.Unanchored.cs`; `UtilitiesCS.Test/UtilitiesCS.Test.csproj`; `/spec.md`; `/issue.md`; this plan file; and files under `/evidence/`. A task that finds it needs any other file halts and reports to the orchestrator. +- N1-LINES are the source lines of: the `if` block in `BreadcrumbBridgeRouter.HandleLeafToggleAsync` whose condition tests `row.IsCollapsed && !row.IsAnchoredCollapse`; the `if` block in `FolderBreadcrumbBridgeRouter.ToggleAsync` whose condition compares `row.CollapsedAfterIndex` with `row.ActiveSegmentIndex`; the matching `if` block in `BreadcrumbStateModel.Expand`; the `UnhandledArrowMessage` arm of `BreadcrumbBridgeCoordinator.PublishRouterOutputs`; and the `UnhandledArrowMessage` arm of `BreadcrumbBridgeCoordinator.RaiseSyntheticArrowKey`. The executor locates them by reading those member bodies in the current source and reads their hits from the post-processed Cobertura document (async members report under their compiler-generated state-machine class). +- Every command-step artifact carries `Timestamp:`, `Command:`, `EXIT_CODE:`, and `Output Summary:`; an artifact that records an expected non-zero exit carries `ExpectedExitCode:`. Committed evidence is Markdown only (`CLAUDE.md` "Committed Test Evidence Format"). Commits are owned by the orchestrator; this plan carries no commit task, so scope gates read `git status --porcelain --untracked-files=all`. +- No test creates a file, sleeps, delays, reads the clock, retries, or carries `[DoNotParallelize]`; no `[ExcludeFromCodeCoverage]` or `coverage.config` entry is added. + +## Pre-Mortem + +- Review checklists: P5-T16 walks the policy-audit, code-review, and feature-audit checklists into `/evidence/qa-gates/self-review..md`; B-1 has a fail-before (P1-T2) and a pass-after (P2-T2) artifact; the N-2 rename map is recorded in P4-T3 as the code review requests. +- Coverage: P0-T4 and P5-T4 record repository line and branch figures against the 80 and 75 percent floors; P5-T5 compares per-file figures for the four N-1 production files and confirms every N1-LINES line is hit; no production C# line changes, so no changed-line figure applies, and no coverage exclusion is added. +- Mirror parity: not applicable for push-down mirrors: no `.claude`, `.agents`, `.codex`, `.github`, or bundled resource file changes, and no fact stated by the execution-environment brief changes, so no brief-update task exists. The page-script and stylesheet contract is held on the C# side by the new string-contract test, because CI runs no node suite. +- File size: every edited test file is well below 500 lines (P0-T5 records counts); the two files near the limit receive no new tests (D4); P5-T11 confirms every touched file is at most 500 physical lines. +- Test isolation: Jest, Python, and Pester isolation runs are not applicable because no TypeScript, Python, or PowerShell file changes. Adapted to MSTest, P5-T9 runs each of the six touched test classes in its own fresh process and then all six in one process under the parallel runsettings, with no worker override and no retry; the full suite runs in parallel in P5-T4. P5-T8 runs the node suites in normal and reversed file order. +- CI-only behavior: CI's mstest-coverage job runs the new string contract, which stands in for the node suites CI does not run; QuickFiler.Test compiles at C# 7.3 (D4); FORMAT runs before every final gate for format-check; evidence is scanned for host paths for the hygiene check (P5-T12). +- Evidence: every artifact path is stated per task under `/evidence//`, carries the four required fields, records `SCRATCH` instead of host paths, and contains no raw trx or coverage document (P5-T12); the manual Outlook check is a human step the orchestrator records. + +### Phase 0 — Policy Reads and Minimal Baseline + +- [x] [P0-T1] Read, in `policy-compliance-order` order, `CLAUDE.md`, `.claude/rules/general-code-change.md`, `.claude/rules/general-unit-test.md`, `.claude/rules/csharp.md`, then `.claude/rules/plan-acceptance-gates.md`, `.claude/skills/execution-environment/SKILL.md`, `.claude/skills/evidence-and-timestamp-conventions/SKILL.md`, `.claude/skills/acceptance-criteria-tracking/SKILL.md`, `/remediation-inputs.2026-10-25T23-25.md`, `/code-review.2026-10-25T23-25.md`, and the `## Acceptance Criteria` section of `/spec.md`; write `/evidence/remediation-baseline/phase0-instructions-read..md` with `Timestamp:`, `Policy Order:`, the branch name, the output of `git status --porcelain --untracked-files=all`, and the files read. Acceptance: the artifact names every file in this task and the porcelain capture lists no path outside `/` (this plan file and the review artifacts may still be uncommitted). +- [x] [P0-T2] Run only `dotnet tool run csharpier check .` (read-only); write `/evidence/remediation-baseline/baseline-format..md`. Acceptance: the artifact records the exit code and either the line beginning `Checked ` or the list of drifting files. +- [x] [P0-T3] Run TYPECHECK on `TaskMaster.sln` (this rebuild also produces the assemblies P0-T4 measures); write `/evidence/remediation-baseline/baseline-typecheck..md`. Acceptance: the artifact records the exit code and the `Error(s)` count. +- [x] [P0-T4] Run TEST-COVERAGE; write `/evidence/remediation-baseline/baseline-coverage..md`. Acceptance: the artifact records passed, failed, and total counts, BASELINE-FAILURES by name (the failing tests of this run, expected none), the one-line first-party coverage summary, numeric repository line and branch percentages, numeric per-file line and branch coverage for `QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs`, `UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.Descendants.cs`, `UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs`, and `QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs` read from the post-processed Cobertura document, and the hit count of every N1-LINES line; no value is a placeholder. Copy that Cobertura document to `SCRATCH` as `issue-1007-r1-baseline-cobertura.xml` for P5-T5; the copy is never committed. +- [x] [P0-T5] Record physical line counts (not `Measure-Object -Line`) of every existing WRITE-SET C# file; write `/evidence/remediation-baseline/baseline-file-sizes..md`. Acceptance: one numeric count per file, including `BreadcrumbBridgeCoordinatorIssue1007Tests.cs` (which receives only the P3-T5 modifier edit) and the main `FolderBreadcrumbBridgeRouterIssue1007Tests.cs` (which is not edited). + +### Phase 1 — B-1 Regression Test (fail first) + +- [x] [P1-T1] In `QuickFiler.Test/Viewers/FolderBreadcrumbKeysIssue1007Tests.cs`, add `Stylesheet_StylesPendingDescendantLikeSelectedSubfolder` as D2 specifies, in MSTest with FluentAssertions and Arrange-Act-Assert sections, with an XML summary citing #1007, AC4, and B-1; extend the class summary to say the class also pins the stylesheet rule. Acceptance: the file compiles under C# 7.3, has no `#nullable` directive, is at most 500 physical lines, and the test body carries the literals `.sub.selected`, `.sub.active`, `var(--bc-sel-back)`, and `var(--bc-sel-fore)`. +- [x] [P1-T2] [expect-fail] Build with ANALYZE, then SCOPED-TEST with filter `FullyQualifiedName~FolderBreadcrumbKeysIssue1007Tests`; write `/evidence/regression-testing/qfc-pending-style-fail-before..md` with `ExpectedExitCode: 1`. Acceptance: the build reports `0 Error(s)`; the trx summary lists `Stylesheet_StylesPendingDescendantLikeSelectedSubfolder` as Failed with a message containing `.sub.active` (so the failure is the missing rule, not the `.sub.selected` positive control); the other eight tests of the class are Passed. + +### Phase 2 — B-1 Fix + +- [x] [P2-T1] In `QuickFiler/Resources/FolderBreadcrumb.html`, change the selector of the `.sub.selected` rule in the ``; `QuickFiler/Resources/FolderBreadcrumb.js` shows no diff against `HEAD`. +- [x] [P2-T2] Rebuild with ANALYZE (a full `/t:Rebuild`, so the HTML linked through `QuickFiler/Properties/Resources.resx` is re-embedded), then SCOPED-TEST with filter `FullyQualifiedName~FolderBreadcrumbKeysIssue1007Tests|FullyQualifiedName~FolderBreadcrumbAssetContractTests`; write `/evidence/regression-testing/qfc-pending-style-pass-after..md`. Acceptance: `0 Error(s)`; `Stylesheet_StylesPendingDescendantLikeSelectedSubfolder` is Passed; zero tests fail. A stale assembly would keep the new test failing, so a pass cannot come from an unrebuilt resource. + +### Phase 3 — N-1 Direct Tests + +These are characterization tests of unchanged behavior: they pass on their first run, and P3-T8 records that run. + +- [x] [P3-T1] In `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue1007Tests.DoubleClick.cs`, add `LeafExpandToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChain`: `ThreeSegmentAsync`, `DoubleClickAsync` at segment 1, an inbound `segmentActivate` for `row-0` at segment 0, then `ExpandAsync` on `row-0`; it asserts the latest render contains `data-segment-index="2"` (Beta shown again) and `VerifyFetched` on `Acme` with `Times.Never()`. The XML summary cites #1007 R3 and N-1. Acceptance: the file compiles under C# 7.3 and is at most 500 physical lines; the existing `ArrowRight_OnRowCollapsedAwayFromActiveSegment_RestoresChain` is unchanged. +- [x] [P3-T2] Create `UtilitiesCS.Test/OutlookObjects/Folder/FolderBreadcrumbBridgeRouterIssue1007Tests.Unanchored.cs` (`#nullable enable`; `public sealed partial class FolderBreadcrumbBridgeRouterIssue1007Tests`) with `AffordanceToggle_OnRowCollapsedAwayFromActiveSegment_RestoresChainWithoutQuery`: `RouterAsync`, `router.Model.Rows[0].CollapseAfter(1)` with the leaf still active, then `ExpandAlphaAsync`; it asserts a single output whose JSON carries `"type":"render"`, `CollapsedAfterIndex` null, `LeafExpanded` false, and `GetImmediateSubfoldersAsync` never called for `Key(Alpha)` or `Key(Projects)`. The XML summary cites #1007 R3, N-1, and the public `CollapseAfter` contract (D3). Acceptance: the file declares no member that the main partial declares and carries no `[TestClass]` attribute of its own. +- [x] [P3-T3] Add `` to `UtilitiesCS.Test/UtilitiesCS.Test.csproj` next to the entry for `FolderBreadcrumbBridgeRouterIssue1007Tests.cs`. Acceptance: the project file contains that entry exactly once and has no other diff against `HEAD`. +- [x] [P3-T4] In `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs`, add `RightArrow_OnRowCollapsedAwayFromActiveSegment_RestoresChain`: `ModelWithSuggestion`, `SelectedRow.CollapseAfter(0)` with the leaf still active, then `RightArrow()`; it asserts `true`, `CollapsedAfterIndex` null, `LeafExpanded` false, and `ActiveSegmentIndex` 2. The XML summary cites #1007 R3, N-1, and D3. Acceptance: the file is at most 500 physical lines. +- [x] [P3-T5] In `QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.cs`, change the class declaration to `public sealed partial class BreadcrumbBridgeCoordinatorIssue1007Tests`, with no other edit. Acceptance: `git diff --numstat HEAD` for the file reports one added and one deleted line. +- [x] [P3-T6] Create `QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorIssue1007Tests.Unhandled.cs` (no `#nullable` directive; C# 7.3 syntax only; `public sealed partial class BreadcrumbBridgeCoordinatorIssue1007Tests`) with `OpenUnhandledArrowLeft_OnExpandedPendingRow_RaisesUnhandledArrowFromRouterOutput`: a `Harness` with `commit: 0`, `open: true`, `expandAlpha: true`, then `Receive` of an `unhandledArrow` message with direction `left`; it asserts `Events` equals the single entry `unhandled:Left`, `KeyDowns` is 1, the selector is still open, and the selected folder is still Alpha. The XML summary cites #1007 R8, N-1, and D3. Acceptance: the file declares no member that the main partial declares and carries no `[TestClass]` attribute of its own. +- [x] [P3-T7] Add `` to `QuickFiler.Test/QuickFiler.Test.csproj` next to the entry for `BreadcrumbBridgeCoordinatorIssue1007Tests.cs`. Acceptance: the project file contains that entry exactly once and has no other diff against `HEAD`. +- [x] [P3-T8] Build with ANALYZE, then SCOPED-TEST with filter `FullyQualifiedName~BreadcrumbBridgeRouterIssue1007Tests|FullyQualifiedName~FolderBreadcrumbBridgeRouterIssue1007Tests|FullyQualifiedName~BreadcrumbStateModelTests|FullyQualifiedName~BreadcrumbBridgeCoordinatorIssue1007Tests`; write `/evidence/regression-testing/n1-direct-tests..md`. Acceptance: `0 Error(s)`; the four P3 tests are each listed as Passed; zero tests fail. + +### Phase 4 — N-2 Renames + +- [x] [P4-T1] In `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs`, rename the three methods listed for that file in D5, keeping bodies, attributes, and XML summaries unchanged. Acceptance: the file contains each new name once and no old name; `git diff --numstat HEAD` for the file reports three added and three deleted lines. +- [x] [P4-T2] In `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbStateModelSequenceTests.cs`, rename the three methods listed for that file in D5 the same way. Acceptance: the file contains each new name once and no old name; apart from the P3-T4 addition, the only changed lines are the three renamed signatures. +- [x] [P4-T3] Search every `.cs` and `.mjs` file in the worktree for the six old and six new names; write `/evidence/other/test-renames..md` with the D5 map, the per-name match counts, and a note that historical names in Markdown documents are left unchanged. Acceptance: each old name has zero matches and each new name exactly one. + +### Phase 5 — Final QA Loop, AC Check-Off, and Self-Review + +Loop rule: P5-T1 to P5-T4 form one toolchain pass. If any of them fails or changes a file, fix the cause and restart at P5-T1; a fresh artifact is written for each step of the pass recorded as clean. Editing `spec.md` or `issue.md` checkboxes and writing Markdown evidence changes no toolchain input and does not restart the loop. + +- [x] [P5-T1] Capture `git status --porcelain --untracked-files=all`, run `dotnet tool run csharpier format .`, capture the status again, then run `dotnet tool run csharpier check .`; write `/evidence/qa-gates/final-toolchain-format..md` with both captures. Acceptance: the captures are identical (format rewrote nothing) and the check exits 0 with a line beginning `Checked `; a rewrite restarts the loop, and a rewrite of a file outside WRITE-SET halts the plan. +- [x] [P5-T2] Run ANALYZE on `TaskMaster.sln`; write `/evidence/qa-gates/final-toolchain-analyzers..md`. Acceptance: `EXIT_CODE: 0`, `0 Error(s)`, and no warning whose path is a WRITE-SET file. +- [x] [P5-T3] Run TYPECHECK on `TaskMaster.sln`; write `/evidence/qa-gates/final-toolchain-typecheck..md`. Acceptance: `EXIT_CODE: 0` and `0 Error(s)`. +- [x] [P5-T4] Run TEST-COVERAGE; write `/evidence/qa-gates/final-toolchain-tests..md` with the counts, the one-line first-party coverage summary, and a list derived from the trx. Acceptance: zero failed outside BASELINE-FAILURES; the five new tests and the six renamed tests appear as Passed; total tests equal the P0-T4 total plus five; repository line coverage is at least 80 percent and branch coverage at least 75 percent. +- [x] [P5-T5] Write `/evidence/qa-gates/coverage-comparison..md` from `SCRATCH` `issue-1007-r1-baseline-cobertura.xml` and the P5-T4 post-processed Cobertura document: baseline and final repository line and branch percentages, the four P0-T4 per-file figures beside their final values, the baseline and final hits of every N1-LINES line, and a search of `coverage.config` and the WRITE-SET diff showing no exclusion added. Acceptance: no final figure is below its baseline, every N1-LINES line has at least one hit, and no exclusion is added; a shortfall is recorded as remediation-required, not PASS. +- [x] [P5-T6] Copy the JaCoCo package projection written by the P5-T4 run (`coverage/coverage.cobertura.jacoco.xml`) to `artifacts/csharp/coverage.xml`; write `/evidence/qa-gates/coverage-artifact..md` with both files' SHA-256 hashes, the `counter` element counts by type, and `git check-ignore` and `git ls-files` results for the copy. Acceptance: the hashes are equal, the copy has `counter` elements of type `LINE` and `BRANCH`, and the copy is ignored and untracked. +- [x] [P5-T7] Search `QuickFiler/Resources/FolderBreadcrumb.html` for `.sub.active` and record the match with its enclosing rule; write `/evidence/qa-gates/qfc-pending-style..md`. Acceptance: exactly one match, inside the `