diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index e29ad26a..d1ce0111 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -1450,10 +1450,7 @@ private WinUI.NavigationView MountNavigationView(NavigationViewElement nav, Acti } if (nav.Content is not null) nv.Content = Mount(nav.Content, requestRerender); if (nav.SelectedTag is not null) - { - foreach (var mi in nv.MenuItems.OfType()) - if (mi.Tag as string == nav.SelectedTag) { nv.SelectedItem = mi; break; } - } + nv.SelectedItem = FindNavItemByTag(nv.MenuItems, nav.SelectedTag); SetElementTag(nv, nav); if (nav.OnSelectedTagChanged is not null) nv.SelectionChanged += (s, args) => diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs index 7e57c40b..26b88896 100644 --- a/src/Reactor/Core/Reconciler.Update.cs +++ b/src/Reactor/Core/Reconciler.Update.cs @@ -1750,17 +1750,17 @@ void FinalizeOldPage(UIElement? oldCtrl, Element? oldElem, object? oldRoute) if (!double.IsNaN(n.CompactModeThresholdWidth) && nv.CompactModeThresholdWidth != n.CompactModeThresholdWidth) nv.CompactModeThresholdWidth = n.CompactModeThresholdWidth; if (!double.IsNaN(n.ExpandedModeThresholdWidth) && nv.ExpandedModeThresholdWidth != n.ExpandedModeThresholdWidth) nv.ExpandedModeThresholdWidth = n.ExpandedModeThresholdWidth; + // Reconcile menu items in place rather than clear-and-rebuild. The old + // rebuild recreated every NavigationViewItem on each render, and since + // per-item expansion (IsExpanded) lives only on the live container — it + // is not modeled in NavigationViewItemData — every re-render snapped all + // expanded hierarchical items shut. Because consumers typically pass a + // fresh MenuItems array each render (e.g. built via LINQ), the + // ReferenceEquals guard never held and the rebuild fired constantly, + // producing the "expand then collapse" flash and "child click collapses + // parent". Reusing containers by Tag preserves IsExpanded across renders. if (!ReferenceEquals(o.MenuItems, n.MenuItems)) - { - nv.MenuItems.Clear(); - foreach (var item in n.MenuItems) - { - if (item.IsHeader) - nv.MenuItems.Add(new WinUI.NavigationViewItemHeader { Content = item.Content }); - else - nv.MenuItems.Add(CreateNavItem(item)); - } - } + ReconcileNavMenuItems(nv.MenuItems, o.MenuItems, n.MenuItems); // AutoSuggestBox / PaneFooter / PaneCustomContent reconcile in place // when possible so the controls keep focus / scroll state across re-renders. @@ -1835,6 +1835,124 @@ void FinalizeOldPage(UIElement? oldCtrl, Element? oldElem, object? oldRoute) return null; } + /// + /// Brings a live NavigationView menu-item collection into agreement with + /// while reusing existing + /// containers (matched by Tag) so their runtime IsExpanded/selection + /// state survives the re-render. Recurses into hierarchical children. + /// + private void ReconcileNavMenuItems( + global::System.Collections.Generic.IList live, + NavigationViewItemData[]? oldData, + NavigationViewItemData[] newData) + { + // Fast path: the structure (order of headers + item Tags) is unchanged — + // the overwhelmingly common case, since menus are usually static. Update + // each container in place without detaching anything, which keeps every + // container's expansion, selection and animation state pristine. + if (NavStructureMatches(live, newData)) + { + for (int i = 0; i < newData.Length; i++) + { + var data = newData[i]; + if (data.IsHeader) + { + if (live[i] is WinUI.NavigationViewItemHeader h && !Equals(h.Content, data.Content)) + h.Content = data.Content; + } + else if (live[i] is WinUI.NavigationViewItem nvi) + { + var oldItem = oldData is not null && i < oldData.Length ? oldData[i] : null; + UpdateNavItemInPlace(nvi, oldItem, data); + } + } + return; + } + + // Structure changed (items added / removed / reordered): snapshot the + // existing containers by Tag so matches can be reused, then rebuild the + // collection in the new order. Reused containers retain their IsExpanded. + var reusable = new global::System.Collections.Generic.Dictionary(); + foreach (var nvi in live.OfType().Where(x => x.Tag is string)) + reusable[(string)nvi.Tag] = nvi; + + var oldByTag = new global::System.Collections.Generic.Dictionary(); + if (oldData is not null) + foreach (var d in oldData.Where(d => !d.IsHeader)) + oldByTag[d.Tag ?? d.Content] = d; + + live.Clear(); + foreach (var data in newData) + { + if (data.IsHeader) + { + live.Add(new WinUI.NavigationViewItemHeader { Content = data.Content }); + continue; + } + + // Consume the reuse entry so duplicate sibling keys (duplicate Tags, + // or duplicate Content when no Tag is set) fall through to a fresh + // container rather than adding the same WinUI item to live twice. + var key = data.Tag ?? data.Content; + if (reusable.Remove(key, out var nvi)) + UpdateNavItemInPlace(nvi, oldByTag.GetValueOrDefault(key), data); + else + nvi = CreateNavItem(data); + live.Add(nvi); + } + } + + /// True when the live collection already matches the new data in + /// count and in the sequence of headers / item Tags. + private static bool NavStructureMatches( + global::System.Collections.Generic.IList live, + NavigationViewItemData[] newData) + { + if (live.Count != newData.Length) return false; + for (int i = 0; i < newData.Length; i++) + { + var data = newData[i]; + if (data.IsHeader) + { + if (live[i] is not WinUI.NavigationViewItemHeader) return false; + } + else + { + if (live[i] is not WinUI.NavigationViewItem nvi) return false; + if ((nvi.Tag as string) != (data.Tag ?? data.Content)) return false; + } + } + return true; + } + + /// Updates a reused NavigationViewItem's Content/Icon (only when + /// changed) and reconciles its children, leaving IsExpanded untouched. + private void UpdateNavItemInPlace(WinUI.NavigationViewItem nvi, NavigationViewItemData? oldData, NavigationViewItemData data) + { + if (!Equals(nvi.Content, data.Content)) nvi.Content = data.Content; + + var newTag = data.Tag ?? data.Content; + if (!Equals(nvi.Tag, newTag)) nvi.Tag = newTag; + + // Re-resolve the icon only when the icon data actually changed (IconData + // records compare by value), so the FontIcon/SymbolIcon isn't reallocated + // on every render. + bool iconChanged = oldData is null + || !Equals(oldData.IconElement, data.IconElement) + || oldData.Icon != data.Icon; + if (iconChanged) + { + var icon = ResolveIcon(data.IconElement, data.Icon); + if (icon is not null) nvi.Icon = icon; + else if (nvi.Icon is not null) nvi.Icon = null; + } + + if (data.Children is { Length: > 0 } children) + ReconcileNavMenuItems(nvi.MenuItems, oldData?.Children, children); + else if (nvi.MenuItems.Count > 0) + nvi.MenuItems.Clear(); + } + private UIElement? UpdateTitleBar(TitleBarElement o, TitleBarElement n, WinUI.TitleBar titleBar, Action requestRerender) { titleBar.Title = n.Title; diff --git a/src/Reactor/Core/V1Protocol/Descriptor/Descriptors/NavigationViewDescriptor.cs b/src/Reactor/Core/V1Protocol/Descriptor/Descriptors/NavigationViewDescriptor.cs index 60ed32e7..b5d499d4 100644 --- a/src/Reactor/Core/V1Protocol/Descriptor/Descriptors/NavigationViewDescriptor.cs +++ b/src/Reactor/Core/V1Protocol/Descriptor/Descriptors/NavigationViewDescriptor.cs @@ -123,8 +123,9 @@ internal static class NavigationViewDescriptor private static void ApplyMenuAndSelection(WinUI.NavigationView control, NavigationViewElement? oldElement, NavigationViewElement element) { - if (oldElement is null || !ReferenceEquals(oldElement.MenuItems, element.MenuItems)) + if (oldElement is null) { + // Mount: build fresh. control.MenuItems.Clear(); foreach (var item in element.MenuItems) { @@ -133,6 +134,13 @@ private static void ApplyMenuAndSelection(WinUI.NavigationView control, Navigati : CreateNavItem(item)); } } + else if (!ReferenceEquals(oldElement.MenuItems, element.MenuItems)) + { + // Update: reconcile in place so reused containers keep IsExpanded. + // Mirrors Reconciler.ReconcileNavMenuItems (legacy arm) — see the note + // there on why a clear-and-rebuild collapses hierarchical items. + ReconcileMenuItems(control.MenuItems, oldElement.MenuItems, element.MenuItems); + } if (oldElement is null || oldElement.SelectedTag != element.SelectedTag @@ -142,6 +150,107 @@ private static void ApplyMenuAndSelection(WinUI.NavigationView control, Navigati } } + private static void ReconcileMenuItems( + global::System.Collections.Generic.IList live, + NavigationViewItemData[]? oldData, + NavigationViewItemData[] newData) + { + if (StructureMatches(live, newData)) + { + for (int i = 0; i < newData.Length; i++) + { + var data = newData[i]; + if (data.IsHeader) + { + if (live[i] is WinUI.NavigationViewItemHeader h && !Equals(h.Content, data.Content)) + h.Content = data.Content; + } + else if (live[i] is WinUI.NavigationViewItem nvi) + { + var oldItem = oldData is not null && i < oldData.Length ? oldData[i] : null; + UpdateNavItemInPlace(nvi, oldItem, data); + } + } + return; + } + + var reusable = new global::System.Collections.Generic.Dictionary(); + foreach (var nvi in live.OfType().Where(x => x.Tag is string)) + reusable[(string)nvi.Tag] = nvi; + + var oldByTag = new global::System.Collections.Generic.Dictionary(); + if (oldData is not null) + foreach (var d in oldData.Where(d => !d.IsHeader)) + oldByTag[d.Tag ?? d.Content] = d; + + live.Clear(); + foreach (var data in newData) + { + if (data.IsHeader) + { + live.Add(new WinUI.NavigationViewItemHeader { Content = data.Content }); + continue; + } + + // Consume the reuse entry so duplicate sibling keys fall through to a + // fresh container rather than adding the same WinUI item to live twice. + var key = data.Tag ?? data.Content; + if (reusable.Remove(key, out var nvi)) + UpdateNavItemInPlace(nvi, oldByTag.GetValueOrDefault(key), data); + else + nvi = CreateNavItem(data); + live.Add(nvi); + } + } + + private static bool StructureMatches( + global::System.Collections.Generic.IList live, + NavigationViewItemData[] newData) + { + if (live.Count != newData.Length) return false; + for (int i = 0; i < newData.Length; i++) + { + var data = newData[i]; + if (data.IsHeader) + { + if (live[i] is not WinUI.NavigationViewItemHeader) return false; + } + else + { + if (live[i] is not WinUI.NavigationViewItem nvi) return false; + if ((nvi.Tag as string) != (data.Tag ?? data.Content)) return false; + } + } + return true; + } + + private static void UpdateNavItemInPlace(WinUI.NavigationViewItem nvi, NavigationViewItemData? oldData, NavigationViewItemData data) + { + if (!Equals(nvi.Content, data.Content)) nvi.Content = data.Content; + + var newTag = data.Tag ?? data.Content; + if (!Equals(nvi.Tag, newTag)) nvi.Tag = newTag; + + bool iconChanged = oldData is null + || !Equals(oldData.IconElement, data.IconElement) + || oldData.Icon != data.Icon; + if (iconChanged) + { + var icon = data.IconElement is not null + ? Reconciler.ResolveIconForDescriptor(data.IconElement) + : data.Icon is not null + ? Reconciler.ResolveIconForDescriptor(new SymbolIconData(data.Icon)) + : null; + if (icon is not null) nvi.Icon = icon; + else if (nvi.Icon is not null) nvi.Icon = null; + } + + if (data.Children is { Length: > 0 } children) + ReconcileMenuItems(nvi.MenuItems, oldData?.Children, children); + else if (nvi.MenuItems.Count > 0) + nvi.MenuItems.Clear(); + } + private static WinUI.NavigationViewItem CreateNavItem(NavigationViewItemData data) { var item = new WinUI.NavigationViewItem { Content = data.Content, Tag = data.Tag ?? data.Content }; diff --git a/tests/Reactor.AppTests.Host/FixtureRegistry.cs b/tests/Reactor.AppTests.Host/FixtureRegistry.cs index 23456be7..4250d765 100644 --- a/tests/Reactor.AppTests.Host/FixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/FixtureRegistry.cs @@ -61,9 +61,17 @@ internal static class FixtureRegistry // Collections "ListView_TypedRendering", + // TreeView expand/collapse (mirrors ReactorGallery Basic TreeView — + // E2E repro/guard for the item-body-collapse bug) + "TreeView_BasicTextTree", + // Navigation "Navigation_TabSwitching", + // NavigationView hierarchical expand/collapse (E2E repro/guard for the + // re-render rebuild-clobber that collapsed expanded categories) + "NavigationView_Hierarchical", + // Observable "Observable_UseObservable_Rerender", "Observable_UseObservable_ExternalMutation", @@ -205,9 +213,15 @@ internal static class FixtureRegistry // Collections "ListView_TypedRendering" => CollectionFixtures.ListViewTyped(ctx), + // TreeView expand/collapse + "TreeView_BasicTextTree" => TreeViewE2EFixtures.BasicTextTree(ctx), + // Navigation "Navigation_TabSwitching" => NavigationFixtures.TabSwitching(ctx), + // NavigationView hierarchical expand/collapse + "NavigationView_Hierarchical" => NavigationViewE2EFixtures.HierarchicalNav(ctx), + // Observable "Observable_UseObservable_Rerender" => ObservableFixtures.UseObservable_Rerender(ctx), "Observable_UseObservable_ExternalMutation" => ObservableFixtures.UseObservable_ExternalMutation(ctx), diff --git a/tests/Reactor.AppTests.Host/Fixtures/NavigationViewE2EFixtures.cs b/tests/Reactor.AppTests.Host/Fixtures/NavigationViewE2EFixtures.cs new file mode 100644 index 00000000..a0f523d2 --- /dev/null +++ b/tests/Reactor.AppTests.Host/Fixtures/NavigationViewE2EFixtures.cs @@ -0,0 +1,66 @@ +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Xaml.Controls; +using static Microsoft.UI.Reactor.Factories; + +namespace Microsoft.UI.Reactor.AppTests.Host.Fixtures; + +/// +/// E2E fixtures for hierarchical NavigationView expand/collapse, mirroring +/// the ReactorGallery shell shape: a stateful component that holds the selected +/// tag, re-renders on every selection, and rebuilds its MenuItems array +/// fresh each render (the condition that made the old clear-and-rebuild update +/// path collapse expanded categories on every re-render). +/// +/// PaneDisplayMode is pinned to Left so the pane stays open with text +/// labels (addressable by UIA Name) and children render inline when expanded, +/// regardless of the host window size. +/// +internal static class NavigationViewE2EFixtures +{ + internal class HierarchicalNavComponent : Component + { + public override Element Render() + { + var (selectedTag, setSelectedTag) = UseState("alpha-1"); + + // Rebuilt fresh every render — exactly the pattern the Gallery uses + // and the one that exposed the rebuild-clobber bug. + var menuItems = new[] + { + NavItem("Alpha", tag: "alpha") with + { + Children = new[] + { + NavItem("Alpha-1", tag: "alpha-1"), + NavItem("Alpha-2", tag: "alpha-2"), + } + }, + NavItem("Bravo", tag: "bravo") with + { + Children = new[] + { + NavItem("Bravo-1", tag: "bravo-1"), + } + }, + }; + + return NavigationView( + menuItems, + content: TextBlock($"Selected: {selectedTag}").AutomationId("NavSelectedTag") + ) with + { + SelectedTag = selectedTag, + PaneDisplayMode = NavigationViewPaneDisplayMode.Left, + IsSettingsVisible = false, + OnSelectedTagChanged = tag => + { + if (tag != null) setSelectedTag(tag); + }, + }; + } + } + + internal static Element HierarchicalNav(RenderContext ctx) => + Component(); +} diff --git a/tests/Reactor.AppTests.Host/Fixtures/TreeViewE2EFixtures.cs b/tests/Reactor.AppTests.Host/Fixtures/TreeViewE2EFixtures.cs new file mode 100644 index 00000000..58e186df --- /dev/null +++ b/tests/Reactor.AppTests.Host/Fixtures/TreeViewE2EFixtures.cs @@ -0,0 +1,57 @@ +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using static Microsoft.UI.Reactor.Factories; + +namespace Microsoft.UI.Reactor.AppTests.Host.Fixtures; + +/// +/// E2E fixtures for the legacy text-node TreeView expand/collapse path. +/// +/// This deliberately mirrors the ReactorGallery "Basic TreeView" card +/// (samples/ReactorGallery/ControlPages/Collections/TreeViewPage.cs): +/// a plain text tree built with TreeView(TreeNode(...)), with +/// no OnExpanding/OnItemInvoked handlers and no component +/// state. That "no handlers, no state" shape is important — clicking a node +/// triggers no Reactor re-render, so it isolates the WinUI-level expand/collapse +/// behavior (see c:\temp\treeview-expand-collapse-investigation.md §5). +/// +/// The tree is rendered with the top two levels pre-expanded so that collapse +/// is observable through the UIA tree: a node's children are only realized +/// (and therefore only findable by automation) while that node is expanded. +/// +internal static class TreeViewE2EFixtures +{ + // The node text values double as the WinAppDriver UIA Names the tests look + // up. Keep them unique so Name-based lookup is unambiguous. + internal static Element BasicTextTree(RenderContext ctx) + { + static TreeViewNodeData Expanded(TreeViewNodeData n) => n with { IsExpanded = true }; + + return VStack(8, + TextBlock("Basic text TreeView — mirrors ReactorGallery Collections → TreeView (no handlers, no state).") + .AutomationId("TreeViewCaption"), + + TreeView( + // Documents / Work pre-expanded → Report.docx + Slides.pptx are + // initially visible, so a collapse caused by clicking the item + // body (or a child) is detectable. + Expanded(TreeNode("Documents", + Expanded(TreeNode("Work", + TreeNode("Report.docx"), + TreeNode("Slides.pptx"))), + TreeNode("Personal", + TreeNode("Budget.xlsx")))), + + // Pictures stays collapsed → exercises the "click item body to + // expand, expansion should stick" path. + TreeNode("Pictures", + TreeNode("Vacation", + TreeNode("Beach.jpg"), + TreeNode("Mountain.jpg")), + TreeNode("Family")), + + TreeNode("Music") + ).Height(300).AutomationId("BasicTreeView") + ); + } +} diff --git a/tests/Reactor.AppTests/Tests/NavigationViewInteractionTests.cs b/tests/Reactor.AppTests/Tests/NavigationViewInteractionTests.cs new file mode 100644 index 00000000..687ef33d --- /dev/null +++ b/tests/Reactor.AppTests/Tests/NavigationViewInteractionTests.cs @@ -0,0 +1,126 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.UI.Reactor.AppTests.Infrastructure; +using OpenQA.Selenium; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Appium.Windows; +using OpenQA.Selenium.Support.UI; + +namespace Microsoft.UI.Reactor.AppTests.Tests; + +/// +/// E2E expand/collapse tests for the hierarchical NavigationView, driving +/// the real WinUI control through WinAppDriver via the +/// NavigationView_Hierarchical host fixture (which mirrors the +/// ReactorGallery shell: stateful, re-renders on every selection, rebuilds its +/// MenuItems array each render). +/// +/// These reproduce the rebuild-clobber bug: before the fix, every selection +/// re-render cleared and recreated all NavigationViewItems, so an expanded +/// category snapped shut on the same click that selected it, and selecting a +/// child collapsed its parent. The fix reconciles menu items in place (matching +/// by Tag) so the container's IsExpanded survives the re-render. +/// +/// Unlike the TreeView gesture bug, this one is triggered by the re-render itself +/// (a real SelectionChanged event), so WinAppDriver reproduces it deterministically. +/// +[TestClass] +public class NavigationViewInteractionTests : AppTestBase +{ + private const string Fixture = "NavigationView_Hierarchical"; + + [ClassInitialize] + public static void StartAppSession(TestContext context) => TestSession.AssemblyInit(context); + + [ClassCleanup] + public static void StopAppSession() => TestSession.AssemblyCleanup(); + + /// + /// Clicking a collapsed parent category expands it and the expansion sticks + /// across the selection-triggered re-render (its children stay visible). + /// + [TestMethod] + public void ExpandParent_StaysExpandedAfterRerender() + { + NavigateToFixtureFresh(Fixture); + + // Parent starts collapsed → children not realized. + Assert.IsFalse(IsItemPresent("Alpha-1"), "Alpha should start collapsed."); + + ClickItem("Alpha"); + Settle(); + + Assert.IsTrue(WaitForItemPresent("Alpha-1"), + "Expanding 'Alpha' did not stick — its child 'Alpha-1' is not visible after the " + + "selection re-render (rebuild-clobber regression)."); + } + + /// + /// Selecting a child must NOT collapse its parent. Expand "Alpha", click child + /// "Alpha-1", and verify the sibling "Alpha-2" remains visible (parent stayed + /// expanded) and the selection updated. + /// + [TestMethod] + public void SelectChild_DoesNotCollapseParent() + { + NavigateToFixtureFresh(Fixture); + + ClickItem("Alpha"); + Settle(); + Assert.IsTrue(WaitForItemPresent("Alpha-2"), "Alpha should be expanded with its children visible."); + + ClickItem("Alpha-1"); + Settle(); + + WaitForText("NavSelectedTag", "Selected: alpha-1"); + Assert.IsTrue(IsItemPresent("Alpha-2"), + "Selecting child 'Alpha-1' collapsed its parent 'Alpha' " + + "(sibling 'Alpha-2' disappeared) — rebuild-clobber regression."); + } + + // ── helpers ───────────────────────────────────────────────────── + + private void ClickItem(string itemText) => + Session.FindElement(MobileBy.Name(itemText)).Click(); + + private bool IsItemPresent(string itemText) + { + try + { + Session.Manage().Timeouts().ImplicitWait = TimeSpan.Zero; + Session.FindElement(MobileBy.Name(itemText)); + return true; + } + catch (WebDriverException) + { + return false; + } + finally + { + Session.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(2); + } + } + + private bool WaitForItemPresent(string itemText, int timeoutMs = 4000) + { + var wait = new DefaultWait>(Session) + { + Timeout = TimeSpan.FromMilliseconds(timeoutMs), + PollingInterval = TimeSpan.FromMilliseconds(150), + }; + wait.IgnoreExceptionTypes(typeof(WebDriverException)); + try + { + return wait.Until(driver => + { + try { driver.FindElement(MobileBy.Name(itemText)); return true; } + catch (WebDriverException) { return false; } + }); + } + catch (WebDriverTimeoutException) + { + return false; + } + } + + private static void Settle() => Thread.Sleep(600); +} diff --git a/tests/Reactor.AppTests/Tests/TreeViewInteractionTests.cs b/tests/Reactor.AppTests/Tests/TreeViewInteractionTests.cs new file mode 100644 index 00000000..2cc68b82 --- /dev/null +++ b/tests/Reactor.AppTests/Tests/TreeViewInteractionTests.cs @@ -0,0 +1,181 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.UI.Reactor.AppTests.Infrastructure; +using OpenQA.Selenium; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Appium.Windows; +using OpenQA.Selenium.Support.UI; + +namespace Microsoft.UI.Reactor.AppTests.Tests; + +/// +/// E2E expand/collapse tests for the legacy text-node TreeView, driving +/// the real WinUI control through WinAppDriver. These exercise the same path as +/// the ReactorGallery "Basic TreeView" card via the TreeView_BasicTextTree +/// host fixture. +/// +/// Why E2E and not the headless self-test harness: the bug only reproduces in a +/// real, on-screen window with real pointer input — the headless host lays out +/// at full desired size and does not reproduce the constrained-viewport / +/// container-recycling timing (see +/// c:\temp\treeview-expand-collapse-investigation.md §6). So these tests +/// are the automated repro surface AND the future regression guard. +/// +/// How expand/collapse is observed: in node-mode TreeView, a node's children are +/// only realized (and therefore only present in the UIA tree) while that node is +/// expanded. So "is descendant X findable by Name?" is a faithful proxy for "is +/// its ancestor expanded?". +/// +/// Gesture under test: clicks the node's text (the +/// center of the content area) — NOT the expand/collapse chevron on the far left. +/// Per the investigation, the chevron works correctly; clicking the row body is +/// what misbehaves in the live GUI. +/// +/// CURRENT STATUS (main, 2026-05): both tests PASS — i.e. a WinAppDriver-injected +/// item-body click does NOT collapse the node, and clicking a child does NOT +/// collapse its parent. That is itself useful signal: the reported collapse does +/// not reproduce under automated pointer injection on a data-pre-expanded tree, +/// which narrows the suspect surface toward human-gesture timing and/or the +/// user-expands-then-it-collapses sequence (investigation §4/§5) rather than the +/// steady-state mount path these tests cover. The tests stand as (a) a live-GUI +/// debugging harness for the gallery path — set REACTOR_TV_TRACE=1 and navigate +/// to TreeView_BasicTextTree — and (b) a regression guard that the steady-state +/// expand/collapse path stays healthy. If a future change makes an item-body or +/// child click collapse the tree, these go red. +/// +[TestClass] +public class TreeViewInteractionTests : AppTestBase +{ + private const string Fixture = "TreeView_BasicTextTree"; + + [ClassInitialize] + public static void StartAppSession(TestContext context) => TestSession.AssemblyInit(context); + + [ClassCleanup] + public static void StopAppSession() => TestSession.AssemblyCleanup(); + + /// + /// Symptom 1: clicking a node's item body (not the chevron) must NOT collapse + /// it. "Work" starts expanded, so "Report.docx" is visible; after clicking the + /// "Work" row body the child must remain visible. + /// + [TestMethod] + public void ClickItemBody_DoesNotCollapseExpandedNode() + { + NavigateToFixtureFresh(Fixture); + + // Precondition: Work is expanded → its children are realized. + AssertNodeVisible("Report.docx", "Work should start expanded."); + + ClickNodeBody("Work"); + Settle(); + + // Prove the click actually landed on the row (otherwise a "stayed visible" + // pass would be vacuous): an item-body click in SelectionMode.Single must + // select the node. + Assert.IsTrue(IsNodeSelected("Work"), + "Item-body click did not register — 'Work' is not selected, so the " + + "'no collapse' assertion below would be meaningless."); + + AssertNodeVisible( + "Report.docx", + "Clicking the 'Work' row body collapsed it (its child 'Report.docx' disappeared). " + + "The item body should not toggle expansion — only the chevron should."); + } + + /// + /// Symptom 2: clicking a child item must NOT collapse its parent. Click + /// "Report.docx" (a leaf child of "Work") and verify "Work" stays expanded + /// (its sibling "Slides.pptx" remains visible). + /// + [TestMethod] + public void ClickChild_DoesNotCollapseParent() + { + NavigateToFixtureFresh(Fixture); + + AssertNodeVisible("Slides.pptx", "Work should start expanded."); + + ClickNodeBody("Report.docx"); + Settle(); + + AssertNodeVisible( + "Slides.pptx", + "Clicking child 'Report.docx' collapsed its parent 'Work' " + + "(sibling 'Slides.pptx' disappeared)."); + } + + // ── helpers ───────────────────────────────────────────────────── + + /// + /// Clicks the body (text/content area) of a tree node, addressed by its text. + /// The element found by Name is the node's TextBlock, whose center is over the + /// content — well clear of the chevron on the far left. + /// + private void ClickNodeBody(string nodeText) + { + Session.FindElement(MobileBy.Name(nodeText)).Click(); + } + + /// + /// Whether the tree node addressed by its text reports UIA selection + /// (SelectionItem pattern). Used to prove an item-body click registered. + /// + private bool IsNodeSelected(string nodeText) + { + try + { + return Session.FindElement(MobileBy.Name(nodeText)).Selected; + } + catch (WebDriverException) + { + return false; + } + } + + private void AssertNodeVisible(string nodeText, string because) + { + Assert.IsTrue(WaitForNodePresent(nodeText), + $"Expected tree node '{nodeText}' to be visible. {because}"); + } + + /// + /// Polls until a node with the given Name is present, or the timeout elapses. + /// Returns whether the node became present. + /// + private bool WaitForNodePresent(string nodeText, int timeoutMs = 4000) + { + var wait = new DefaultWait>(Session) + { + Timeout = TimeSpan.FromMilliseconds(timeoutMs), + PollingInterval = TimeSpan.FromMilliseconds(150), + }; + wait.IgnoreExceptionTypes(typeof(WebDriverException)); + try + { + // DefaultWait keeps polling while the lambda returns the default value + // (false for bool), so returning false on not-found retries until the + // timeout; returning true ends the wait successfully. + return wait.Until(driver => + { + try + { + driver.FindElement(MobileBy.Name(nodeText)); + return true; + } + catch (WebDriverException) + { + return false; // keep polling + } + }); + } + catch (WebDriverTimeoutException) + { + return false; + } + } + + /// + /// Lets any expand/collapse animation or reconcile-driven "flash" settle so + /// assertions observe the final state, not a transient mid-toggle frame. + /// + private static void Settle() => Thread.Sleep(600); +}