-
Notifications
You must be signed in to change notification settings - Fork 779
Partially addresses #4522: document NeedsLayout invariant + lock in layout-convergence guards #5500
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+422
−11
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
117783e
#4522: Document NeedsLayout invariant, complete content-size re-read,…
harder eb52d01
#4522: Address Codex review findings — fix three test quality issues
harder 52d2c78
#4522: Address workflow review findings
harder 354a9bc
#4522: Fix render fingerprint review follow-up
harder 125d3ae
Delete plans/4522-needslayout-lifecycle.md
harder cc605e6
Apply suggestions from code review
harder File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
131 changes: 131 additions & 0 deletions
131
Tests/UnitTestsParallelizable/ViewBase/Layout/AllViewsRenderFingerprintTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| // Claude - Opus 4.8 | ||
| // All-views render-fingerprint harness for issue #4522 verification. Draws every concrete View type | ||
| // (via EnableForDesign) onto a test driver and emits a hash of the rendered screen per view. Used to | ||
| // diff rendering before/after the LayoutSubViews content-size change: identical fingerprints prove the | ||
| // change is render-neutral across all views. Kept as a coarse all-views smoke test. | ||
|
|
||
| using System.Security.Cryptography; | ||
| using System.Text; | ||
| using UnitTests; | ||
|
|
||
| namespace ViewBaseTests.Layout; | ||
|
|
||
| public class AllViewsRenderFingerprintTests (ITestOutputHelper output) : TestsAllViews | ||
| { | ||
| private static string Hash (string s) | ||
| { | ||
| byte [] bytes = SHA256.HashData (Encoding.UTF8.GetBytes (s)); | ||
|
|
||
| return Convert.ToHexString (bytes) [..16]; | ||
| } | ||
|
|
||
| [Fact] | ||
| public void AllViews_Render_Fingerprint () | ||
| { | ||
| List<Type> types = GetAllViewClasses () | ||
| .Where (t => !t.IsGenericType) | ||
| .OrderBy (t => t.FullName, StringComparer.Ordinal) | ||
| .ToList (); | ||
|
|
||
| StringBuilder combined = new (); | ||
| List<string> threw = []; | ||
| int successful = 0; | ||
|
|
||
| foreach (Type type in types) | ||
| { | ||
| string line = FingerprintOne (type); | ||
| output.WriteLine (line); | ||
| combined.AppendLine (line); | ||
|
|
||
| if (line.Contains ("|EX:")) | ||
| { | ||
| threw.Add (line); | ||
| } | ||
| else if (!line.Contains ("|GENERIC") && !line.Contains ("|ENV:")) | ||
| { | ||
| successful++; | ||
| } | ||
| } | ||
|
|
||
| output.WriteLine ($"--- {successful} view types successfully rendered ---"); | ||
| output.WriteLine ($"COMBINED={Hash (combined.ToString ())}"); | ||
|
|
||
| // Guarantee we're actually exercising a meaningful number of concrete view types. | ||
| Assert.True (successful > 30, $"Expected to render many view types, got {successful}."); | ||
|
|
||
| // No concrete view may throw while being laid out and drawn in design mode. | ||
| Assert.True (threw.Count == 0, $"View(s) threw during layout/draw:\n{string.Join ("\n", threw)}"); | ||
| } | ||
|
|
||
| private static string FingerprintOne (Type type) | ||
| { | ||
| IDriver driver = CreateTestDriver (60, 20); | ||
|
|
||
| View? view = CreateInstanceIfNotGeneric (type); | ||
|
|
||
| if (view is null) | ||
| { | ||
| driver.Dispose (); | ||
|
|
||
| return $"{type.FullName}|GENERIC"; | ||
| } | ||
|
|
||
| view.Driver = driver; | ||
|
|
||
| // EnableForDesign for filesystem-interactive views (FileDialog family) attempts to list | ||
| // directories during design setup or the later initialization/layout pass. That is an | ||
| // environment constraint, not a layout bug — separate it from layout/draw exceptions. | ||
| try | ||
| { | ||
| if (view is IDesignable designable) | ||
| { | ||
| designable.EnableForDesign (); | ||
| } | ||
| } | ||
| catch (Exception ex) when (IsFileDialogEnvironmentException (view, ex)) | ||
| { | ||
| view.Dispose (); | ||
| driver.Dispose (); | ||
|
|
||
| return $"{type.FullName}|ENV:{ex.GetType ().Name}"; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| view.BeginInit (); | ||
| view.EndInit (); | ||
| view.Layout (); | ||
|
|
||
| string fingerprint = $"frame={view.Frame};needsLayout={view.NeedsLayout}"; | ||
|
|
||
| if (view.Visible) | ||
| { | ||
| view.SetNeedsDraw (); | ||
| view.Draw (); | ||
| fingerprint += $";screen={Hash (driver.ToString () ?? string.Empty)}"; | ||
| } | ||
|
|
||
| view.Dispose (); | ||
| driver.Dispose (); | ||
|
|
||
| return $"{type.FullName}|{fingerprint}"; | ||
| } | ||
| catch (Exception ex) when (IsFileDialogEnvironmentException (view, ex)) | ||
| { | ||
| view.Dispose (); | ||
| driver.Dispose (); | ||
|
|
||
| return $"{type.FullName}|ENV:{ex.GetType ().Name}"; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| view.Dispose (); | ||
| driver.Dispose (); | ||
|
|
||
| return $"{type.FullName}|EX:{ex.GetType ().Name}"; | ||
| } | ||
| } | ||
|
|
||
| private static bool IsFileDialogEnvironmentException (View view, Exception ex) => | ||
| view is FileDialog && ex is UnauthorizedAccessException or IOException; | ||
| } | ||
195 changes: 195 additions & 0 deletions
195
Tests/UnitTestsParallelizable/ViewBase/Layout/LayoutConvergenceTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| // Claude - Opus 4.8 | ||
|
|
||
|
harder marked this conversation as resolved.
|
||
| namespace ViewBaseTests.Layout; | ||
|
|
||
| /// <summary> | ||
| /// Regression guards for the deterministic-layout properties relevant to issue #4522 | ||
| /// (<c>NeedsLayout</c> is buggy). These pin the behavior established by #5357 (split upward / | ||
| /// downward <see cref="View.SetNeedsLayout"/> propagation), #5358 and #5359 (region-aware, | ||
| /// non-escalating draw): a single property change must converge in one layout pass, must not | ||
| /// change frames it didn't need to, and must not fan out layout work across sibling subtrees. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Before those fixes, changing one view re-laid-out and redrew large portions of the tree | ||
| /// (see the analysis in #4522 and the measured fan-out in #4973). These tests fail loudly if | ||
| /// that regression returns. | ||
| /// </remarks> | ||
| public class LayoutConvergenceTests (ITestOutputHelper output) | ||
| { | ||
| private sealed class Counters | ||
| { | ||
| public int LaidOut; | ||
| public int FrameChanged; | ||
|
|
||
| public void Attach (View v) | ||
| { | ||
| v.SubViewsLaidOut += (_, _) => LaidOut++; | ||
| v.FrameChanged += (_, _) => FrameChanged++; | ||
| } | ||
| } | ||
|
|
||
| private static void ClearTree (View v) | ||
| { | ||
| v.NeedsLayout = false; | ||
|
|
||
| foreach (View sv in v.SubViews) | ||
| { | ||
| ClearTree (sv); | ||
| } | ||
| } | ||
|
|
||
| // Builds root -> chain of `depth` levels, with `breadth` ABSOLUTE-sized siblings at each level. | ||
| // The chain follows the first sibling at each level. Returns the deepest leaf to mutate. | ||
| private static (View root, View leaf, List<View> all) BuildAbsoluteTree (int depth, int breadth) | ||
| { | ||
| View root = new () { Id = "root", Width = 200, Height = 200 }; | ||
| List<View> all = [root]; | ||
| View current = root; | ||
| View leaf = root; | ||
|
|
||
| for (var d = 0; d < depth; d++) | ||
| { | ||
| View next = current; | ||
|
|
||
| for (var b = 0; b < breadth; b++) | ||
| { | ||
| View child = new () { Id = $"d{d}b{b}", X = b * 10, Y = d, Width = 8, Height = 1 }; | ||
| current.Add (child); | ||
| all.Add (child); | ||
|
|
||
| if (b == 0) | ||
| { | ||
| next = child; | ||
| } | ||
| } | ||
|
|
||
|
harder marked this conversation as resolved.
|
||
| current = next; | ||
| leaf = current; | ||
| } | ||
|
|
||
| return (root, leaf, all); | ||
| } | ||
|
|
||
| [Theory] | ||
| [InlineData (3, 3)] | ||
| [InlineData (6, 3)] | ||
| [InlineData (8, 4)] | ||
| public void Absolute_Child_Change_Converges_Single_Pass_Without_FanOut (int depth, int breadth) | ||
| { | ||
| (View root, View leaf, List<View> all) = BuildAbsoluteTree (depth, breadth); | ||
| root.BeginInit (); | ||
| root.EndInit (); | ||
| root.Layout (); | ||
|
|
||
| Counters c = new (); | ||
|
|
||
| foreach (View v in all) | ||
| { | ||
| c.Attach (v); | ||
| } | ||
|
|
||
| ClearTree (root); | ||
|
|
||
| // A single absolute-size change on the deepest leaf. | ||
| leaf.Width = leaf.Frame.Width + 1; | ||
|
|
||
| // Drive application-style layout passes; a healthy engine converges immediately. | ||
| var passes = 0; | ||
|
|
||
| while (root.NeedsLayout && passes < 20) | ||
| { | ||
| root.Layout (); | ||
| passes++; | ||
| } | ||
|
|
||
| output.WriteLine ($"depth={depth} breadth={breadth} totalViews={all.Count} passes={passes} laidOut={c.LaidOut} frameChanged={c.FrameChanged}"); | ||
|
|
||
| // Converges in a single application layout pass — no multi-iteration thrashing (Bug #6). | ||
| Assert.Equal (1, passes); | ||
|
|
||
| // Only the leaf's frame actually changed — no spurious frame churn up/across the tree (Bug #1). | ||
| Assert.Equal (1, c.FrameChanged); | ||
|
|
||
| // Layout work stays on the affected ancestor chain (root..leaf); siblings/cousins do not | ||
| // re-run LayoutSubViews. The chain is depth+1 views; allow one extra for the root pass. | ||
| Assert.True ( | ||
| c.LaidOut <= depth + 2, | ||
| $"Layout fan-out {c.LaidOut} exceeded the ancestor chain bound {depth + 2}."); | ||
|
|
||
| // And with wide breadth the fan-out is far below the total view count (no subtree fan-out, #5357). | ||
| Assert.True ( | ||
| c.LaidOut < all.Count, | ||
| $"Layout fan-out {c.LaidOut} touched the whole tree of {all.Count} views."); | ||
|
|
||
| root.Dispose (); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void DimAuto_Parent_Converges_Single_Pass_And_Tracks_Child_Growth () | ||
| { | ||
| View root = new () { Id = "root", Width = 200, Height = 200 }; | ||
| View autoParent = new () { Id = "autoParent", Width = Dim.Auto (), Height = Dim.Auto () }; | ||
| View child = new () { Id = "child", Width = 10, Height = 3 }; | ||
| autoParent.Add (child); | ||
| root.Add (autoParent); | ||
|
|
||
| root.BeginInit (); | ||
| root.EndInit (); | ||
| root.Layout (); | ||
|
|
||
| child.Width = 40; | ||
|
|
||
|
harder marked this conversation as resolved.
Outdated
|
||
| var passes = 0; | ||
|
|
||
| while (root.NeedsLayout && passes < 20) | ||
| { | ||
| root.Layout (); | ||
| passes++; | ||
| } | ||
|
|
||
| output.WriteLine ($"Dim.Auto parent grew to {autoParent.Frame.Size} in {passes} pass(es)"); | ||
|
|
||
| // The parent must track the child's new width and converge in a single pass. | ||
| // (In this test the upward mark from child.Width = 40 is sufficient; the SetFrame | ||
| // upward mark is not additionally exercised by this synthetic case.) | ||
| Assert.Equal (40, autoParent.Frame.Width); | ||
| Assert.Equal (1, passes); | ||
|
|
||
| root.Dispose (); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Sibling_Reference_ReLayout_Stays_Single_Pass () | ||
| { | ||
| // A sibling that references the changed view (Pos.Right) must be repositioned — this is the | ||
| // case where the ancestor re-layout is necessary, not wasteful. It must still be single-pass. | ||
| View root = new () { Id = "root", Width = 80, Height = 25 }; | ||
| View anchor = new () { Id = "anchor", X = 0, Y = 0, Width = 10, Height = 1 }; | ||
| View follower = new () { Id = "follower", X = Pos.Right (anchor), Y = 0, Width = 5, Height = 1 }; | ||
| root.Add (anchor, follower); | ||
|
|
||
| root.BeginInit (); | ||
| root.EndInit (); | ||
| root.Layout (); | ||
|
|
||
| Assert.Equal (10, follower.Frame.X); | ||
|
|
||
| anchor.Width = 20; | ||
|
|
||
| var passes = 0; | ||
|
|
||
| while (root.NeedsLayout && passes < 20) | ||
| { | ||
| root.Layout (); | ||
| passes++; | ||
| } | ||
|
|
||
| output.WriteLine ($"follower repositioned to X={follower.Frame.X} in {passes} pass(es)"); | ||
|
|
||
| // The follower must track the anchor's new right edge, and converge in one pass. | ||
| Assert.Equal (20, follower.Frame.X); | ||
| Assert.Equal (1, passes); | ||
|
|
||
| root.Dispose (); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.