From 78c76de8a6f92c1891534dc8495f1ae071007d6e Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 21 May 2026 09:19:32 -0500 Subject: [PATCH 1/3] Fixes #5356. Add tab fan-out layout/draw diagnostic tests Adds TabsFanOutDiagnosticTests with seven parallelizable tests that observe SubViewsLaidOut, DrawComplete, ClearedViewport, DrawingContent, and FrameChanged events per tab to measure layout/draw fan-out when an active TextView inside Tabs scrolls. Captures the current #4973 behavior so that future fixes can be verified and silent regressions caught: - Active tab vs. inactive tab layout work - Active tab vs. inactive tab draw work (DrawComplete, ClearedViewport, DrawingContent) - A comparable single-TextView baseline vs. tabbed scenario fan-out ratio - ViewportSettings.Transparent does not mask the diagnostic - Shadow margin does not mask active-tab activity - IOutput-level output (not Driver.Contents) is observed - Layout and draw counters reported separately so regressions can be localized to one pipeline The tests are instrumentation-only and do not change rendering or invalidation semantics. --- .../TabView/TabsFanOutDiagnosticTests.cs | 566 ++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs diff --git a/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs b/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs new file mode 100644 index 0000000000..af1549dfd6 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs @@ -0,0 +1,566 @@ +using System.Text; +using UnitTests; + +namespace ViewsTests; + +// Claude - Opus 4.7 + +/// +/// Diagnostic tests for issue #5356 — tabbed redraw/layout fan-out. +/// +/// +/// +/// These tests are intentionally instrumentation-only: they observe layout and draw activity +/// on each tab via , , and +/// events, but do not change rendering or invalidation +/// semantics. They lock in the current behavior so that future refactors of the layout/draw +/// pipeline (see issue #4973) can be measured and verified. +/// +/// +/// The diagnostics rely on counters/event traces — not on Driver.Contents alone — +/// so they remain meaningful in the presence of clipping, transparent viewports, +/// shadow margins, and adornment subviews. +/// +/// +/// Each test reports its measured counts through so the data +/// is visible in CI logs even when the test passes. When the fan-out problem is fixed, these +/// tests are expected to be updated to assert the new (lower) counts; the diagnostic helper +/// and the test scaffolding remain useful as a regression check. +/// +/// +public class TabsFanOutDiagnosticTests (ITestOutputHelper output) : TestDriverBase +{ + /// + /// Records per-view layout and draw activity for a set of tracked views. Used as the primary + /// diagnostic for tab fan-out — the counts here are deterministic and survive future changes + /// to clipping, shadow, transparency, and adornment-subview behavior. + /// + private sealed class ViewActivityCounters + { + private readonly Dictionary _counts = new (); + private readonly List<(View View, string Label)> _order = []; + + public void Track (View view, string label) + { + Counts counts = new (); + _counts [view] = counts; + _order.Add ((view, label)); + + view.SubViewsLaidOut += (_, _) => counts.SubViewsLaidOut++; + view.DrawComplete += (_, _) => counts.DrawComplete++; + view.ClearedViewport += (_, _) => counts.ClearedViewport++; + view.DrawingContent += (_, _) => counts.DrawingContent++; + view.FrameChanged += (_, _) => counts.FrameChanged++; + } + + public Counts Get (View view) => _counts [view]; + + public void Reset () + { + foreach (Counts counts in _counts.Values) + { + counts.Reset (); + } + } + + public string Report (string title) + { + StringBuilder sb = new (); + sb.AppendLine (title); + sb.AppendLine (" view laidOut drawComplete clearedViewport drawingContent frameChanged"); + + foreach ((View view, string label) in _order) + { + Counts c = _counts [view]; + + sb.AppendLine ( + $" {label,-26} {c.SubViewsLaidOut,7} {c.DrawComplete,12} {c.ClearedViewport,15} {c.DrawingContent,14} {c.FrameChanged,12}"); + } + + return sb.ToString (); + } + + public sealed class Counts + { + public int SubViewsLaidOut; + public int DrawComplete; + public int ClearedViewport; + public int DrawingContent; + public int FrameChanged; + + public void Reset () + { + SubViewsLaidOut = 0; + DrawComplete = 0; + ClearedViewport = 0; + DrawingContent = 0; + FrameChanged = 0; + } + } + } + + private const int TabCount = 4; + private const int DriverWidth = 60; + private const int DriverHeight = 20; + + private static string MakeText (string prefix, int lines) + { + StringBuilder sb = new (); + + for (var i = 1; i <= lines; i++) + { + sb.Append (prefix); + sb.Append (' '); + sb.Append (i); + + if (i < lines) + { + sb.Append ('\n'); + } + } + + return sb.ToString (); + } + + private static (View Root, Tabs Tabs, TextView [] TextViews, ViewActivityCounters Counters) BuildTabbedScenario (IDriver driver) + { + View root = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + root.Add (tabs); + + TextView [] textViews = new TextView [TabCount]; + + for (var i = 0; i < TabCount; i++) + { + TextView tv = new () + { + Title = $"Tab{i + 1}", + Text = MakeText ($"Tab{i + 1} line", 50), + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + textViews [i] = tv; + tabs.Add (tv); + } + + ViewActivityCounters counters = new (); + counters.Track (tabs, "Tabs"); + + for (var i = 0; i < TabCount; i++) + { + counters.Track (textViews [i], $"TextView{i + 1}"); + } + + return (root, tabs, textViews, counters); + } + + private static (View Root, TextView TextView, ViewActivityCounters Counters) BuildSingleTextViewScenario (IDriver driver) + { + View root = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + TextView tv = new () + { + Title = "Only", + Text = MakeText ("Only line", 50), + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + root.Add (tv); + + ViewActivityCounters counters = new (); + counters.Track (tv, "TextView"); + + return (root, tv, counters); + } + + /// + /// Issue #5356 / #4973 acceptance criterion: diagnostics can show whether inactive tabs + /// received layout work when the active tab scrolls. + /// + [Fact] + public void Diagnostic_ActiveTabScroll_LayoutEvents_OnEachTab () + { + IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); + (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + + root.Layout (); + root.Draw (); + + TextView active = (TextView)tabs.Value!; + Assert.Same (textViews [0], active); + + counters.Reset (); + + for (var y = 1; y <= 5; y++) + { + active.Viewport = active.Viewport with { Y = y }; + root.Layout (); + } + + output.WriteLine (counters.Report ("After 5 active-tab scrolls (layout-only):")); + + ViewActivityCounters.Counts activeCounts = counters.Get (active); + Assert.True (activeCounts.SubViewsLaidOut > 0, $"Active tab should receive layout activity, got {activeCounts.SubViewsLaidOut}"); + + int inactiveLayouts = 0; + + for (var i = 1; i < TabCount; i++) + { + inactiveLayouts += counters.Get (textViews [i]).SubViewsLaidOut; + } + + output.WriteLine ($"Active SubViewsLaidOut: {activeCounts.SubViewsLaidOut}"); + output.WriteLine ($"Sum of inactive SubViewsLaidOut: {inactiveLayouts}"); + + // CURRENT BEHAVIOR (issue #4973): inactive tabs receive the same layout count as active. + // After #4973 is fixed, flip this to `Assert.Equal (0, inactiveLayouts)`. + Assert.True ( + inactiveLayouts > 0, + $"Documents issue #4973: inactive tabs receive layout work when active tab scrolls. " + + $"Observed inactive_total={inactiveLayouts}, active={activeCounts.SubViewsLaidOut}. " + + "Flip to Assert.Equal(0, inactiveLayouts) after #4973 fix lands."); + + root.Dispose (); + driver.Dispose (); + } + + /// + /// Issue #5356 / #4973 acceptance criterion: diagnostics can show whether inactive tabs + /// received draw work when the active tab scrolls. + /// + [Fact] + public void Diagnostic_ActiveTabScroll_DrawEvents_OnEachTab () + { + IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); + (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + + root.Layout (); + root.Draw (); + + TextView active = (TextView)tabs.Value!; + + counters.Reset (); + + for (var y = 1; y <= 5; y++) + { + active.Viewport = active.Viewport with { Y = y }; + root.Layout (); + root.Draw (); + } + + output.WriteLine (counters.Report ("After 5 active-tab scrolls (layout + draw):")); + + ViewActivityCounters.Counts activeCounts = counters.Get (active); + Assert.True (activeCounts.DrawComplete > 0, $"Active tab must receive draw activity, got {activeCounts.DrawComplete}"); + + int inactiveDraws = 0; + int inactiveClears = 0; + int inactiveContentDraws = 0; + + for (var i = 1; i < TabCount; i++) + { + ViewActivityCounters.Counts c = counters.Get (textViews [i]); + inactiveDraws += c.DrawComplete; + inactiveClears += c.ClearedViewport; + inactiveContentDraws += c.DrawingContent; + } + + output.WriteLine ($"Active DrawComplete: {activeCounts.DrawComplete}"); + output.WriteLine ($"Sum of inactive DrawComplete: {inactiveDraws}"); + output.WriteLine ($"Sum of inactive ClearedViewport: {inactiveClears}"); + output.WriteLine ($"Sum of inactive DrawingContent: {inactiveContentDraws}"); + + // CURRENT BEHAVIOR (issue #4973): inactive tabs receive draw, clear, and content-draw work + // when the active tab scrolls. After #4973 is fixed, flip these to `Assert.Equal (0, ...)`. + Assert.True ( + inactiveDraws > 0, + $"Documents issue #4973 draw fan-out: inactive_total DrawComplete={inactiveDraws}, " + + $"active={activeCounts.DrawComplete}. Flip to Assert.Equal(0, inactiveDraws) after #4973 fix."); + + Assert.True ( + inactiveClears > 0, + $"Documents issue #4973 clear fan-out: ClearViewport propagates via SetNeedsDraw. " + + $"inactive_total ClearedViewport={inactiveClears}. Flip after #4973 fix."); + + Assert.True ( + inactiveContentDraws > 0, + $"Documents issue #4973 content-draw fan-out: inactive_total DrawingContent={inactiveContentDraws}. " + + "Flip after #4973 fix."); + + root.Dispose (); + driver.Dispose (); + } + + /// + /// Issue #5356 acceptance criterion: a comparable fan-out metric between an equivalent + /// single-TextView scenario and a tabbed-TextView scenario. + /// + [Fact] + public void Diagnostic_TabbedFanOut_ComparedTo_SingleTextViewBaseline () + { + IDriver driverSingle = CreateTestDriver (DriverWidth, DriverHeight); + (View singleRoot, TextView singleTv, ViewActivityCounters singleCounters) = BuildSingleTextViewScenario (driverSingle); + + singleRoot.Layout (); + singleRoot.Draw (); + singleCounters.Reset (); + + for (var y = 1; y <= 5; y++) + { + singleTv.Viewport = singleTv.Viewport with { Y = y }; + singleRoot.Layout (); + singleRoot.Draw (); + } + + ViewActivityCounters.Counts singleCounts = singleCounters.Get (singleTv); + output.WriteLine (singleCounters.Report ("Single-TextView baseline (5 scrolls):")); + + IDriver driverTabbed = CreateTestDriver (DriverWidth, DriverHeight); + (View tabRoot, Tabs tabs, TextView [] textViews, ViewActivityCounters tabCounters) = BuildTabbedScenario (driverTabbed); + + tabRoot.Layout (); + tabRoot.Draw (); + + TextView active = (TextView)tabs.Value!; + tabCounters.Reset (); + + for (var y = 1; y <= 5; y++) + { + active.Viewport = active.Viewport with { Y = y }; + tabRoot.Layout (); + tabRoot.Draw (); + } + + ViewActivityCounters.Counts tabActiveCounts = tabCounters.Get (active); + output.WriteLine (tabCounters.Report ("Tabbed scenario (5 scrolls of active tab):")); + + int totalTabDraws = tabActiveCounts.DrawComplete; + int totalTabLayouts = tabActiveCounts.SubViewsLaidOut; + + for (var i = 1; i < TabCount; i++) + { + totalTabDraws += tabCounters.Get (textViews [i]).DrawComplete; + totalTabLayouts += tabCounters.Get (textViews [i]).SubViewsLaidOut; + } + + double drawFanOut = singleCounts.DrawComplete == 0 ? double.NaN : (double)totalTabDraws / singleCounts.DrawComplete; + double layoutFanOut = singleCounts.SubViewsLaidOut == 0 ? double.NaN : (double)totalTabLayouts / singleCounts.SubViewsLaidOut; + + output.WriteLine ($"Tabbed total draws / single draws = {totalTabDraws} / {singleCounts.DrawComplete} = {drawFanOut:F2}"); + output.WriteLine ($"Tabbed total layouts / single layouts = {totalTabLayouts} / {singleCounts.SubViewsLaidOut} = {layoutFanOut:F2}"); + + Assert.True (singleCounts.DrawComplete > 0, "Single-TextView baseline must record draw activity."); + Assert.True (tabActiveCounts.DrawComplete > 0, "Active tab in tabbed scenario must record draw activity."); + + // CURRENT BEHAVIOR (issue #4973): tabbed scenario produces N-times more total work than baseline. + // After #4973 is fixed, drawFanOut and layoutFanOut should approach 1.0 (within rounding). + Assert.True ( + drawFanOut > 1.0, + $"Documents issue #4973: tabbed draw fan-out ({drawFanOut:F2}x) exceeds single-TextView baseline. " + + "After fix, drawFanOut should approach 1.0."); + + Assert.True ( + layoutFanOut > 1.0, + $"Documents issue #4973: tabbed layout fan-out ({layoutFanOut:F2}x) exceeds single-TextView baseline. " + + "After fix, layoutFanOut should approach 1.0."); + + singleRoot.Dispose (); + tabRoot.Dispose (); + driverSingle.Dispose (); + driverTabbed.Dispose (); + } + + /// + /// Edge case: transparent inactive tabs must not silently bypass the diagnostic. + /// changes the clear/draw sequence in the inner + /// view's draw path but it does not eliminate firing on the + /// view itself. The diagnostic must still observe activity on transparent tabs. + /// + [Fact] + public void Diagnostic_TransparentInactiveTab_StillObservable () + { + IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); + (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + + textViews [1].ViewportSettings |= ViewportSettingsFlags.Transparent; + textViews [2].ViewportSettings |= ViewportSettingsFlags.Transparent; + + root.Layout (); + root.Draw (); + + TextView active = (TextView)tabs.Value!; + counters.Reset (); + + for (var y = 1; y <= 3; y++) + { + active.Viewport = active.Viewport with { Y = y }; + root.Layout (); + root.Draw (); + } + + output.WriteLine (counters.Report ("Transparent inactive tabs (3 active-tab scrolls):")); + + ViewActivityCounters.Counts activeCounts = counters.Get (active); + Assert.True (activeCounts.DrawComplete > 0, "Active tab still draws when peers are transparent."); + + for (var i = 1; i < TabCount; i++) + { + ViewActivityCounters.Counts c = counters.Get (textViews [i]); + + Assert.True ( + c.DrawComplete >= 0, + $"Tab {i + 1} DrawComplete counter must be observable (got {c.DrawComplete}) even with Transparent."); + } + + root.Dispose (); + driver.Dispose (); + } + + /// + /// Edge case: shadow margins draw in a separate pass (). + /// The diagnostic must observe the active tab's draw activity even when its margin has a shadow. + /// + [Fact] + public void Diagnostic_ShadowMargin_DoesNotMaskActiveTabActivity () + { + IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); + (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + + textViews [0].ShadowStyle = ShadowStyles.Opaque; + + root.Layout (); + root.Draw (); + + TextView active = (TextView)tabs.Value!; + Assert.Same (textViews [0], active); + + counters.Reset (); + + for (var y = 1; y <= 3; y++) + { + active.Viewport = active.Viewport with { Y = y }; + root.Layout (); + root.Draw (); + } + + output.WriteLine (counters.Report ("Active tab with shadow margin (3 scrolls):")); + + ViewActivityCounters.Counts activeCounts = counters.Get (active); + Assert.True (activeCounts.DrawComplete > 0, $"Active tab with shadow must still record DrawComplete (got {activeCounts.DrawComplete})."); + + root.Dispose (); + driver.Dispose (); + } + + /// + /// Acceptance criterion: assertions must not depend solely on Driver.Contents. + /// This test confirms the diagnostic can also reach the actual layer — + /// returns the ANSI bytes that would be written to the + /// terminal, which is the source of truth beyond the in-memory Driver.Contents grid. + /// + [Fact] + public void Diagnostic_IOutputLayer_ObservesActiveTabContent () + { + IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); + (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + + root.Layout (); + root.Draw (); + + TextView active = (TextView)tabs.Value!; + + IOutput output1 = driver.GetOutput (); + IOutputBuffer buffer = driver.GetOutputBuffer (); + output1.Write (buffer); + string ansiAfterInitial = output1.GetLastOutput (); + + output.WriteLine ($"Initial GetLastOutput length: {ansiAfterInitial.Length}"); + Assert.False (string.IsNullOrEmpty (ansiAfterInitial), "IOutput must produce non-empty output for initial draw."); + + counters.Reset (); + + active.Viewport = active.Viewport with { Y = 10 }; + root.Layout (); + root.Draw (); + + output1.Write (buffer); + string ansiAfterScroll = output1.GetLastOutput (); + + output.WriteLine ($"After-scroll GetLastOutput length: {ansiAfterScroll.Length}"); + Assert.False (string.IsNullOrEmpty (ansiAfterScroll), "IOutput must produce non-empty output after a scroll."); + + ViewActivityCounters.Counts activeCounts = counters.Get (active); + Assert.True (activeCounts.DrawComplete > 0, "Active tab must draw for IOutput to receive content."); + + Assert.Contains ($"Tab{1} line", ansiAfterInitial); + + output.WriteLine (counters.Report ("IOutput-level check (1 scroll):")); + + root.Dispose (); + driver.Dispose (); + } + + /// + /// Diagnostic reports layout and draw fan-out as separate metrics, so a regression can be + /// localized to one pipeline (layout vs draw) without conflating the two. + /// + [Fact] + public void Diagnostic_LayoutAndDraw_ReportedSeparately () + { + IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); + (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + + root.Layout (); + root.Draw (); + + TextView active = (TextView)tabs.Value!; + counters.Reset (); + + active.Viewport = active.Viewport with { Y = 2 }; + root.Layout (); + root.Draw (); + + int activeLayouts = counters.Get (active).SubViewsLaidOut; + int activeDraws = counters.Get (active).DrawComplete; + + var inactiveLayouts = 0; + var inactiveDraws = 0; + + for (var i = 1; i < TabCount; i++) + { + inactiveLayouts += counters.Get (textViews [i]).SubViewsLaidOut; + inactiveDraws += counters.Get (textViews [i]).DrawComplete; + } + + output.WriteLine ($"Layout: active={activeLayouts}, inactive_total={inactiveLayouts}"); + output.WriteLine ($"Draw: active={activeDraws}, inactive_total={inactiveDraws}"); + output.WriteLine (counters.Report ("Single-scroll layout-vs-draw breakdown:")); + + Assert.True (activeLayouts > 0, $"Active tab must register layout activity, got {activeLayouts}."); + Assert.True (activeDraws > 0, $"Active tab must register draw activity, got {activeDraws}."); + + // The two counters can move independently — RC2 in #4973 affects layout fan-out only, + // RC1 affects draw fan-out only. Reporting them separately lets a regression land in just + // one pipeline without being masked by the other. + output.WriteLine ($"layout_fanout_ratio = {(activeLayouts == 0 ? "n/a" : ((double)inactiveLayouts / activeLayouts).ToString ("F2"))}"); + output.WriteLine ($"draw_fanout_ratio = {(activeDraws == 0 ? "n/a" : ((double)inactiveDraws / activeDraws).ToString ("F2"))}"); + + root.Dispose (); + driver.Dispose (); + } +} From 60b845908f201c6274e03d3c41bda7ae94048c07 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 21 May 2026 09:45:25 -0500 Subject: [PATCH 2/3] Swap TextView for Code; add integration test for fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextView is being deprecated in favor of Code (read-only) and Editor (editable). The fan-out behavior lives in the View base class layout/draw pipeline, so any scrollable view inside Tabs reproduces it — swap to Code so the diagnostic survives the deprecation. Adjusts assertions to use only widget-agnostic signals (DrawComplete on each tab, ClearedViewport on the Tabs container). Code overrides OnClearingViewport/OnDrawingContent and returns true, which suppresses the ClearedViewport/DrawingContent events on the Code instances. The per-Code data is still reported but no longer asserted at that level. Adds Tests/IntegrationTests/TabsFanOutIntegrationTests.cs that drives the active tab via a real Key.PageDown through the input processor → command dispatch → main-loop LayoutAndDraw path (rather than mutating Viewport directly). Runs against all registered drivers. Confirms the fan-out is observable end-to-end: Driver: Active tab Viewport.Y after 3 PageDowns: 3 Tabs 3 3 3 Code1 (active) 3 3 0 Code2-4 (inact) 3 3 0 Sum inactive DrawComplete = 9 --- .../TabsFanOutIntegrationTests.cs | 179 ++++++++++++++++++ .../TabView/TabsFanOutDiagnosticTests.cs | 147 +++++++------- 2 files changed, 255 insertions(+), 71 deletions(-) create mode 100644 Tests/IntegrationTests/TabsFanOutIntegrationTests.cs diff --git a/Tests/IntegrationTests/TabsFanOutIntegrationTests.cs b/Tests/IntegrationTests/TabsFanOutIntegrationTests.cs new file mode 100644 index 0000000000..308d30ab67 --- /dev/null +++ b/Tests/IntegrationTests/TabsFanOutIntegrationTests.cs @@ -0,0 +1,179 @@ +using System.Text; +using AppTestHelpers; + +namespace IntegrationTests; + +// Claude - Opus 4.7 + +/// +/// Integration counterpart to TabsFanOutDiagnosticTests. Drives the active tab via real +/// key injection through the driver's input processor → command dispatch → main-loop +/// LayoutAndDraw path, instead of mutating directly. This +/// verifies the fan-out from issue #4973 / #5356 is observable end-to-end, not just under +/// synthetic / calls. +/// +/// +/// Instrumentation-only. The per-tab counters are attached to event subscriptions on +/// , , and +/// ; no rendering or invalidation behavior is changed. +/// +public class TabsFanOutIntegrationTests (ITestOutputHelper outputHelper) : TestsAllDrivers +{ + private readonly TextWriter _out = new TestOutputWriter (outputHelper); + + /// + /// A that registers / + /// so / + /// drive vertical scrolling through the normal command pipeline. Used only by this test — + /// doesn't expose AddCommand publicly, so a subclass is the + /// simplest way to wire a real input → scroll path without modifying production code. + /// + private sealed class ScrollableCode : Code + { + public ScrollableCode () + { + AddCommand (Command.ScrollDown, () => ScrollVertical (1)); + AddCommand (Command.ScrollUp, () => ScrollVertical (-1)); + + KeyBindings.Add (Key.PageDown, Command.ScrollDown); + KeyBindings.Add (Key.PageUp, Command.ScrollUp); + } + } + + private sealed class Counters + { + public int SubViewsLaidOut; + public int DrawComplete; + public int ClearedViewport; + } + + private static string MakeText (string prefix, int lines) + { + StringBuilder sb = new (); + + for (var i = 1; i <= lines; i++) + { + sb.Append (prefix); + sb.Append (' '); + sb.Append (i); + + if (i < lines) + { + sb.Append ('\n'); + } + } + + return sb.ToString (); + } + + /// + /// End-to-end fan-out check: a real on the active tab causes + /// layout/draw activity on inactive tabs. + /// + [Theory] + [MemberData (nameof (GetAllDriverNames))] + public void Integration_RealPageDown_OnActiveTab_FansOutToInactiveTabs (string driverName) + { + const int TabCount = 4; + + Tabs tabs = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + ScrollableCode [] codes = new ScrollableCode [TabCount]; + + for (var i = 0; i < TabCount; i++) + { + codes [i] = new ScrollableCode + { + Title = $"Tab{i + 1}", + Text = MakeText ($"Tab{i + 1} line", 80), + Language = null, + SyntaxHighlighter = null, + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + tabs.Add (codes [i]); + } + + Counters [] perTab = new Counters [TabCount]; + Counters tabsContainer = new (); + + for (var i = 0; i < TabCount; i++) + { + int captured = i; + perTab [i] = new Counters (); + codes [i].SubViewsLaidOut += (_, _) => perTab [captured].SubViewsLaidOut++; + codes [i].DrawComplete += (_, _) => perTab [captured].DrawComplete++; + codes [i].ClearedViewport += (_, _) => perTab [captured].ClearedViewport++; + } + + tabs.SubViewsLaidOut += (_, _) => tabsContainer.SubViewsLaidOut++; + tabs.DrawComplete += (_, _) => tabsContainer.DrawComplete++; + tabs.ClearedViewport += (_, _) => tabsContainer.ClearedViewport++; + + ScrollableCode active = codes [0]; + + using AppTestHelper helper = With.A (60, 20, driverName, _out) + .Add (tabs) + .Focus (active) + .Then ( + _ => + { + for (var i = 0; i < TabCount; i++) + { + perTab [i].SubViewsLaidOut = 0; + perTab [i].DrawComplete = 0; + perTab [i].ClearedViewport = 0; + } + + tabsContainer.SubViewsLaidOut = 0; + tabsContainer.DrawComplete = 0; + tabsContainer.ClearedViewport = 0; + }) + .KeyDown (Key.PageDown) + .KeyDown (Key.PageDown) + .KeyDown (Key.PageDown); + + outputHelper.WriteLine ($"Driver: {driverName}"); + outputHelper.WriteLine ($"Active tab viewport Y after 3 PageDowns: {active.Viewport.Y}"); + outputHelper.WriteLine ("Per-tab counters (after 3 PageDowns on active tab):"); + outputHelper.WriteLine (" tab laidOut drawComplete clearedViewport"); + outputHelper.WriteLine ($" Tabs {tabsContainer.SubViewsLaidOut,7} {tabsContainer.DrawComplete,12} {tabsContainer.ClearedViewport,15}"); + + for (var i = 0; i < TabCount; i++) + { + outputHelper.WriteLine ($" Code{i + 1,-6} {perTab [i].SubViewsLaidOut,7} {perTab [i].DrawComplete,12} {perTab [i].ClearedViewport,15}"); + } + + Assert.True ( + active.Viewport.Y > 0, + $"PageDown should have scrolled the active tab via real input → command path. Got Viewport.Y={active.Viewport.Y}."); + + Assert.True ( + perTab [0].DrawComplete > 0, + $"Active tab must draw in response to real PageDown, got DrawComplete={perTab [0].DrawComplete}."); + + int inactiveDraws = 0; + int inactiveLayouts = 0; + + for (var i = 1; i < TabCount; i++) + { + inactiveDraws += perTab [i].DrawComplete; + inactiveLayouts += perTab [i].SubViewsLaidOut; + } + + outputHelper.WriteLine ($"Sum inactive DrawComplete = {inactiveDraws}"); + outputHelper.WriteLine ($"Sum inactive SubViewsLaidOut = {inactiveLayouts}"); + + // CURRENT BEHAVIOR (issue #4973): inactive tabs receive draw and layout work when active scrolls, + // even through the real input → command → main-loop path. After #4973 lands, flip these to == 0. + Assert.True ( + inactiveDraws > 0, + $"Documents issue #4973 (integration-level): inactive_total DrawComplete={inactiveDraws}. " + + "Flip to Assert.Equal(0, inactiveDraws) after fix lands."); + + Assert.True ( + inactiveLayouts > 0, + $"Documents issue #4973 (integration-level): inactive_total SubViewsLaidOut={inactiveLayouts}. " + + "Flip to Assert.Equal(0, inactiveLayouts) after fix lands."); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs b/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs index af1549dfd6..acca893ab7 100644 --- a/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs @@ -22,6 +22,13 @@ namespace ViewsTests; /// shadow margins, and adornment subviews. /// /// +/// is used as the scrollable tab content because the older +/// TextView is being deprecated. The fan-out behavior lives in the +/// layout/draw pipeline and is independent of which content widget is used, so any view +/// that scrolls inside an Overlapped-arrangement exercises the same +/// code paths. +/// +/// /// Each test reports its measured counts through so the data /// is visible in CI logs even when the test passes. When the fan-out problem is fixed, these /// tests are expected to be updated to assert the new (lower) counts; the diagnostic helper @@ -122,7 +129,18 @@ private static string MakeText (string prefix, int lines) return sb.ToString (); } - private static (View Root, Tabs Tabs, TextView [] TextViews, ViewActivityCounters Counters) BuildTabbedScenario (IDriver driver) + private static Code MakeCode (string title, string text) => + new () + { + Title = title, + Text = text, + Language = null, + SyntaxHighlighter = null, + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + private static (View Root, Tabs Tabs, Code [] Codes, ViewActivityCounters Counters) BuildTabbedScenario (IDriver driver) { View root = new () { @@ -135,20 +153,12 @@ private static (View Root, Tabs Tabs, TextView [] TextViews, ViewActivityCounter Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; root.Add (tabs); - TextView [] textViews = new TextView [TabCount]; + Code [] codes = new Code [TabCount]; for (var i = 0; i < TabCount; i++) { - TextView tv = new () - { - Title = $"Tab{i + 1}", - Text = MakeText ($"Tab{i + 1} line", 50), - Width = Dim.Fill (), - Height = Dim.Fill () - }; - - textViews [i] = tv; - tabs.Add (tv); + codes [i] = MakeCode ($"Tab{i + 1}", MakeText ($"Tab{i + 1} line", 50)); + tabs.Add (codes [i]); } ViewActivityCounters counters = new (); @@ -156,13 +166,13 @@ private static (View Root, Tabs Tabs, TextView [] TextViews, ViewActivityCounter for (var i = 0; i < TabCount; i++) { - counters.Track (textViews [i], $"TextView{i + 1}"); + counters.Track (codes [i], $"Code{i + 1}"); } - return (root, tabs, textViews, counters); + return (root, tabs, codes, counters); } - private static (View Root, TextView TextView, ViewActivityCounters Counters) BuildSingleTextViewScenario (IDriver driver) + private static (View Root, Code Code, ViewActivityCounters Counters) BuildSingleScenario (IDriver driver) { View root = new () { @@ -172,20 +182,13 @@ private static (View Root, TextView TextView, ViewActivityCounters Counters) Bui Height = Dim.Fill () }; - TextView tv = new () - { - Title = "Only", - Text = MakeText ("Only line", 50), - Width = Dim.Fill (), - Height = Dim.Fill () - }; - - root.Add (tv); + Code code = MakeCode ("Only", MakeText ("Only line", 50)); + root.Add (code); ViewActivityCounters counters = new (); - counters.Track (tv, "TextView"); + counters.Track (code, "Code"); - return (root, tv, counters); + return (root, code, counters); } /// @@ -196,13 +199,13 @@ private static (View Root, TextView TextView, ViewActivityCounters Counters) Bui public void Diagnostic_ActiveTabScroll_LayoutEvents_OnEachTab () { IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); - (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + (View root, Tabs tabs, Code [] codes, ViewActivityCounters counters) = BuildTabbedScenario (driver); root.Layout (); root.Draw (); - TextView active = (TextView)tabs.Value!; - Assert.Same (textViews [0], active); + Code active = (Code)tabs.Value!; + Assert.Same (codes [0], active); counters.Reset (); @@ -221,7 +224,7 @@ public void Diagnostic_ActiveTabScroll_LayoutEvents_OnEachTab () for (var i = 1; i < TabCount; i++) { - inactiveLayouts += counters.Get (textViews [i]).SubViewsLaidOut; + inactiveLayouts += counters.Get (codes [i]).SubViewsLaidOut; } output.WriteLine ($"Active SubViewsLaidOut: {activeCounts.SubViewsLaidOut}"); @@ -247,12 +250,12 @@ public void Diagnostic_ActiveTabScroll_LayoutEvents_OnEachTab () public void Diagnostic_ActiveTabScroll_DrawEvents_OnEachTab () { IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); - (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + (View root, Tabs tabs, Code [] codes, ViewActivityCounters counters) = BuildTabbedScenario (driver); root.Layout (); root.Draw (); - TextView active = (TextView)tabs.Value!; + Code active = (Code)tabs.Value!; counters.Reset (); @@ -274,7 +277,7 @@ public void Diagnostic_ActiveTabScroll_DrawEvents_OnEachTab () for (var i = 1; i < TabCount; i++) { - ViewActivityCounters.Counts c = counters.Get (textViews [i]); + ViewActivityCounters.Counts c = counters.Get (codes [i]); inactiveDraws += c.DrawComplete; inactiveClears += c.ClearedViewport; inactiveContentDraws += c.DrawingContent; @@ -285,22 +288,24 @@ public void Diagnostic_ActiveTabScroll_DrawEvents_OnEachTab () output.WriteLine ($"Sum of inactive ClearedViewport: {inactiveClears}"); output.WriteLine ($"Sum of inactive DrawingContent: {inactiveContentDraws}"); - // CURRENT BEHAVIOR (issue #4973): inactive tabs receive draw, clear, and content-draw work - // when the active tab scrolls. After #4973 is fixed, flip these to `Assert.Equal (0, ...)`. + // CURRENT BEHAVIOR (issue #4973): inactive tabs receive full draw passes when active scrolls. + // DrawComplete is the widget-agnostic signal — it always fires once per call to Draw(), + // regardless of whether the view overrides OnClearingViewport / OnDrawingContent (as Code does). + // After #4973 is fixed, flip this to `Assert.Equal (0, inactiveDraws)`. Assert.True ( inactiveDraws > 0, $"Documents issue #4973 draw fan-out: inactive_total DrawComplete={inactiveDraws}, " + $"active={activeCounts.DrawComplete}. Flip to Assert.Equal(0, inactiveDraws) after #4973 fix."); + // ClearedViewport / DrawingContent on Code instances will be 0 because Code overrides those + // handlers and returns true (suppressing the events). The Tabs container still fires them, + // so they remain useful as a secondary diagnostic — recorded in the report above but not + // asserted at the per-tab level. + ViewActivityCounters.Counts tabsCounts = counters.Get (tabs); Assert.True ( - inactiveClears > 0, - $"Documents issue #4973 clear fan-out: ClearViewport propagates via SetNeedsDraw. " + - $"inactive_total ClearedViewport={inactiveClears}. Flip after #4973 fix."); - - Assert.True ( - inactiveContentDraws > 0, - $"Documents issue #4973 content-draw fan-out: inactive_total DrawingContent={inactiveContentDraws}. " + - "Flip after #4973 fix."); + tabsCounts.ClearedViewport > 0, + $"Tabs container must register clear activity (got {tabsCounts.ClearedViewport}); " + + "this confirms the lower-level draw pipeline is being exercised."); root.Dispose (); driver.Dispose (); @@ -308,13 +313,13 @@ public void Diagnostic_ActiveTabScroll_DrawEvents_OnEachTab () /// /// Issue #5356 acceptance criterion: a comparable fan-out metric between an equivalent - /// single-TextView scenario and a tabbed-TextView scenario. + /// single-Code scenario and a tabbed-Code scenario. /// [Fact] - public void Diagnostic_TabbedFanOut_ComparedTo_SingleTextViewBaseline () + public void Diagnostic_TabbedFanOut_ComparedTo_SingleViewBaseline () { IDriver driverSingle = CreateTestDriver (DriverWidth, DriverHeight); - (View singleRoot, TextView singleTv, ViewActivityCounters singleCounters) = BuildSingleTextViewScenario (driverSingle); + (View singleRoot, Code single, ViewActivityCounters singleCounters) = BuildSingleScenario (driverSingle); singleRoot.Layout (); singleRoot.Draw (); @@ -322,21 +327,21 @@ public void Diagnostic_TabbedFanOut_ComparedTo_SingleTextViewBaseline () for (var y = 1; y <= 5; y++) { - singleTv.Viewport = singleTv.Viewport with { Y = y }; + single.Viewport = single.Viewport with { Y = y }; singleRoot.Layout (); singleRoot.Draw (); } - ViewActivityCounters.Counts singleCounts = singleCounters.Get (singleTv); - output.WriteLine (singleCounters.Report ("Single-TextView baseline (5 scrolls):")); + ViewActivityCounters.Counts singleCounts = singleCounters.Get (single); + output.WriteLine (singleCounters.Report ("Single-Code baseline (5 scrolls):")); IDriver driverTabbed = CreateTestDriver (DriverWidth, DriverHeight); - (View tabRoot, Tabs tabs, TextView [] textViews, ViewActivityCounters tabCounters) = BuildTabbedScenario (driverTabbed); + (View tabRoot, Tabs tabs, Code [] codes, ViewActivityCounters tabCounters) = BuildTabbedScenario (driverTabbed); tabRoot.Layout (); tabRoot.Draw (); - TextView active = (TextView)tabs.Value!; + Code active = (Code)tabs.Value!; tabCounters.Reset (); for (var y = 1; y <= 5; y++) @@ -354,8 +359,8 @@ public void Diagnostic_TabbedFanOut_ComparedTo_SingleTextViewBaseline () for (var i = 1; i < TabCount; i++) { - totalTabDraws += tabCounters.Get (textViews [i]).DrawComplete; - totalTabLayouts += tabCounters.Get (textViews [i]).SubViewsLaidOut; + totalTabDraws += tabCounters.Get (codes [i]).DrawComplete; + totalTabLayouts += tabCounters.Get (codes [i]).SubViewsLaidOut; } double drawFanOut = singleCounts.DrawComplete == 0 ? double.NaN : (double)totalTabDraws / singleCounts.DrawComplete; @@ -364,19 +369,19 @@ public void Diagnostic_TabbedFanOut_ComparedTo_SingleTextViewBaseline () output.WriteLine ($"Tabbed total draws / single draws = {totalTabDraws} / {singleCounts.DrawComplete} = {drawFanOut:F2}"); output.WriteLine ($"Tabbed total layouts / single layouts = {totalTabLayouts} / {singleCounts.SubViewsLaidOut} = {layoutFanOut:F2}"); - Assert.True (singleCounts.DrawComplete > 0, "Single-TextView baseline must record draw activity."); + Assert.True (singleCounts.DrawComplete > 0, "Single-Code baseline must record draw activity."); Assert.True (tabActiveCounts.DrawComplete > 0, "Active tab in tabbed scenario must record draw activity."); // CURRENT BEHAVIOR (issue #4973): tabbed scenario produces N-times more total work than baseline. // After #4973 is fixed, drawFanOut and layoutFanOut should approach 1.0 (within rounding). Assert.True ( drawFanOut > 1.0, - $"Documents issue #4973: tabbed draw fan-out ({drawFanOut:F2}x) exceeds single-TextView baseline. " + + $"Documents issue #4973: tabbed draw fan-out ({drawFanOut:F2}x) exceeds single-Code baseline. " + "After fix, drawFanOut should approach 1.0."); Assert.True ( layoutFanOut > 1.0, - $"Documents issue #4973: tabbed layout fan-out ({layoutFanOut:F2}x) exceeds single-TextView baseline. " + + $"Documents issue #4973: tabbed layout fan-out ({layoutFanOut:F2}x) exceeds single-Code baseline. " + "After fix, layoutFanOut should approach 1.0."); singleRoot.Dispose (); @@ -395,15 +400,15 @@ public void Diagnostic_TabbedFanOut_ComparedTo_SingleTextViewBaseline () public void Diagnostic_TransparentInactiveTab_StillObservable () { IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); - (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + (View root, Tabs tabs, Code [] codes, ViewActivityCounters counters) = BuildTabbedScenario (driver); - textViews [1].ViewportSettings |= ViewportSettingsFlags.Transparent; - textViews [2].ViewportSettings |= ViewportSettingsFlags.Transparent; + codes [1].ViewportSettings |= ViewportSettingsFlags.Transparent; + codes [2].ViewportSettings |= ViewportSettingsFlags.Transparent; root.Layout (); root.Draw (); - TextView active = (TextView)tabs.Value!; + Code active = (Code)tabs.Value!; counters.Reset (); for (var y = 1; y <= 3; y++) @@ -420,7 +425,7 @@ public void Diagnostic_TransparentInactiveTab_StillObservable () for (var i = 1; i < TabCount; i++) { - ViewActivityCounters.Counts c = counters.Get (textViews [i]); + ViewActivityCounters.Counts c = counters.Get (codes [i]); Assert.True ( c.DrawComplete >= 0, @@ -439,15 +444,15 @@ public void Diagnostic_TransparentInactiveTab_StillObservable () public void Diagnostic_ShadowMargin_DoesNotMaskActiveTabActivity () { IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); - (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + (View root, Tabs tabs, Code [] codes, ViewActivityCounters counters) = BuildTabbedScenario (driver); - textViews [0].ShadowStyle = ShadowStyles.Opaque; + codes [0].ShadowStyle = ShadowStyles.Opaque; root.Layout (); root.Draw (); - TextView active = (TextView)tabs.Value!; - Assert.Same (textViews [0], active); + Code active = (Code)tabs.Value!; + Assert.Same (codes [0], active); counters.Reset (); @@ -477,12 +482,12 @@ public void Diagnostic_ShadowMargin_DoesNotMaskActiveTabActivity () public void Diagnostic_IOutputLayer_ObservesActiveTabContent () { IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); - (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + (View root, Tabs tabs, Code [] codes, ViewActivityCounters counters) = BuildTabbedScenario (driver); root.Layout (); root.Draw (); - TextView active = (TextView)tabs.Value!; + Code active = (Code)tabs.Value!; IOutput output1 = driver.GetOutput (); IOutputBuffer buffer = driver.GetOutputBuffer (); @@ -523,12 +528,12 @@ public void Diagnostic_IOutputLayer_ObservesActiveTabContent () public void Diagnostic_LayoutAndDraw_ReportedSeparately () { IDriver driver = CreateTestDriver (DriverWidth, DriverHeight); - (View root, Tabs tabs, TextView [] textViews, ViewActivityCounters counters) = BuildTabbedScenario (driver); + (View root, Tabs tabs, Code [] codes, ViewActivityCounters counters) = BuildTabbedScenario (driver); root.Layout (); root.Draw (); - TextView active = (TextView)tabs.Value!; + Code active = (Code)tabs.Value!; counters.Reset (); active.Viewport = active.Viewport with { Y = 2 }; @@ -543,8 +548,8 @@ public void Diagnostic_LayoutAndDraw_ReportedSeparately () for (var i = 1; i < TabCount; i++) { - inactiveLayouts += counters.Get (textViews [i]).SubViewsLaidOut; - inactiveDraws += counters.Get (textViews [i]).DrawComplete; + inactiveLayouts += counters.Get (codes [i]).SubViewsLaidOut; + inactiveDraws += counters.Get (codes [i]).DrawComplete; } output.WriteLine ($"Layout: active={activeLayouts}, inactive_total={inactiveLayouts}"); From 7e3ccb6413dda8c129fa8bce7215609858333bfa Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 21 May 2026 15:48:52 -0500 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Views/TabView/TabsFanOutDiagnosticTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs b/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs index acca893ab7..3eec4ad54f 100644 --- a/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs @@ -427,9 +427,10 @@ public void Diagnostic_TransparentInactiveTab_StillObservable () { ViewActivityCounters.Counts c = counters.Get (codes [i]); + // Copilot: Lock in current fan-out behavior. After #4973, this should likely become == 0. Assert.True ( - c.DrawComplete >= 0, - $"Tab {i + 1} DrawComplete counter must be observable (got {c.DrawComplete}) even with Transparent."); + c.DrawComplete > 0, + $"Tab {i + 1} should currently still record DrawComplete even with Transparent (got {c.DrawComplete}). Update this expectation after #4973."); } root.Dispose ();