From 05e22ec8abdaaafadf1adc214fe75dc7184609a2 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Sat, 30 Nov 2024 13:25:31 +0100 Subject: [PATCH] Fix random test failures and add empty dispatcher verification to tests (#17628) * Add VerifyEmptyDispatcherAfterTestAttribute * Use VerifyEmptyDispatcherAfterTest and fix failing tests * Remove unsupported timeout from sync xUnit tests --- Avalonia.sln.DotSettings | 1 + build/XUnit.props | 16 ++++---- .../Threading/Dispatcher.Queue.cs | 22 +++++++++++ .../Threading/DispatcherOperation.cs | 15 +++++++- .../Threading/DispatcherPriorityQueue.cs | 19 +++++++++- .../Composition/CompositionAnimationTests.cs | 4 +- .../Input/AccessKeyHandlerTests.cs | 2 +- .../Input/PointerTestsBase.cs | 2 +- ...ayoutableTests_EffectiveViewportChanged.cs | 6 +-- .../Layout/LayoutableTests_LayoutRounding.cs | 6 +-- .../Properties/AssemblyInfo.cs | 4 +- .../ComboBoxTests.cs | 6 ++- .../Properties/AssemblyInfo.cs | 4 +- .../TabControlTests.cs | 2 + .../Avalonia.Headless.UnitTests/InputTests.cs | 6 +-- .../RenderingTests.cs | 8 ++-- .../ServicesTests.cs | 2 +- .../ThreadingTests.cs | 2 +- .../WindowTests.cs | 8 ++-- .../Avalonia.Markup.Xaml.UnitTests.csproj | 4 +- .../Attributes.cs | 4 +- .../ReactiveUserControlTest.cs | 2 +- .../InvariantCultureAttribute.cs | 2 +- ...VerifyEmptyDispatcherAfterTestAttribute.cs | 38 +++++++++++++++++++ 24 files changed, 141 insertions(+), 44 deletions(-) create mode 100644 tests/Avalonia.UnitTests/VerifyEmptyDispatcherAfterTestAttribute.cs diff --git a/Avalonia.sln.DotSettings b/Avalonia.sln.DotSettings index 061db721683..307393c9ab8 100644 --- a/Avalonia.sln.DotSettings +++ b/Avalonia.sln.DotSettings @@ -22,6 +22,7 @@ <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + UI <Policy Inspect="False" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="False" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="False" Prefix="I" Suffix="" Style="AaBb" /> diff --git a/build/XUnit.props b/build/XUnit.props index 8f991ae4425..b4e9708ecde 100644 --- a/build/XUnit.props +++ b/build/XUnit.props @@ -1,14 +1,14 @@  - - - - - - - + + + + + + + - + $(MSBuildThisFileDirectory)\avalonia.snk diff --git a/src/Avalonia.Base/Threading/Dispatcher.Queue.cs b/src/Avalonia.Base/Threading/Dispatcher.Queue.cs index bcb60830b2c..21b1ee8f3aa 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Queue.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Queue.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; @@ -270,4 +271,25 @@ public bool HasJobsWithPriority(DispatcherPriority priority) lock (InstanceLock) return _queue.MaxPriority >= priority; } + + /// + /// Gets all pending jobs, unordered, without removing them. + /// + /// Only use between unit tests! + /// A list of jobs. + internal List GetJobs() + { + lock (InstanceLock) + return _queue.PeekAll(); + } + + /// + /// Clears all pending jobs. + /// + /// Only use between unit tests! + internal void ClearJobs() + { + lock (InstanceLock) + _queue.Clear(); + } } diff --git a/src/Avalonia.Base/Threading/DispatcherOperation.cs b/src/Avalonia.Base/Threading/DispatcherOperation.cs index e72add9d5c1..3bb28a17fed 100644 --- a/src/Avalonia.Base/Threading/DispatcherOperation.cs +++ b/src/Avalonia.Base/Threading/DispatcherOperation.cs @@ -1,12 +1,13 @@ using System; using System.ComponentModel; +using System.Diagnostics; using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; namespace Avalonia.Threading; +[DebuggerDisplay("{DebugDisplay}")] public class DispatcherOperation { protected readonly bool ThrowOnUiThread; @@ -25,7 +26,7 @@ public DispatcherPriority Priority } } - protected object? Callback; + protected internal object? Callback; protected object? TaskSource; internal DispatcherOperation? SequentialPrev { get; set; } @@ -53,6 +54,16 @@ private protected DispatcherOperation(Dispatcher dispatcher, DispatcherPriority Dispatcher = dispatcher; } + internal string DebugDisplay + { + get + { + var method = (Callback as Delegate)?.Method; + var methodDisplay = method is null ? "???" : method.DeclaringType + "." + method.Name; + return $"{methodDisplay} [{Priority}]"; + } + } + /// /// An event that is raised when the operation is aborted or canceled. /// diff --git a/src/Avalonia.Base/Threading/DispatcherPriorityQueue.cs b/src/Avalonia.Base/Threading/DispatcherPriorityQueue.cs index 524b4fab8db..f7ddaab5b15 100644 --- a/src/Avalonia.Base/Threading/DispatcherPriorityQueue.cs +++ b/src/Avalonia.Base/Threading/DispatcherPriorityQueue.cs @@ -398,6 +398,23 @@ private void RemoveItemFromSequentialChain(DispatcherOperation item) // Step 3: cleanup item.SequentialPrev = item.SequentialNext = null; } + + public List PeekAll() + { + var operations = new List(); + + for (var item = _head; item is not null; item = item.SequentialNext) + operations.Add(item); + + return operations; + } + + public void Clear() + { + _priorityChains.Clear(); + _cacheReusableChains.Clear(); + _head = _tail = null; + } } @@ -415,4 +432,4 @@ public PriorityChain(DispatcherPriority priority) // NOTE: should be Priority public DispatcherOperation? Head { get; set; } public DispatcherOperation? Tail { get; set; } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Base.UnitTests/Composition/CompositionAnimationTests.cs b/tests/Avalonia.Base.UnitTests/Composition/CompositionAnimationTests.cs index b1ca75d0180..646e33a841e 100644 --- a/tests/Avalonia.Base.UnitTests/Composition/CompositionAnimationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Composition/CompositionAnimationTests.cs @@ -15,7 +15,7 @@ namespace Avalonia.Base.UnitTests.Composition; -public class CompositionAnimationTests +public class CompositionAnimationTests : ScopedTestBase { class AnimationDataProvider : DataAttribute @@ -114,4 +114,4 @@ public override string ToString() return Name; } } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Base.UnitTests/Input/AccessKeyHandlerTests.cs b/tests/Avalonia.Base.UnitTests/Input/AccessKeyHandlerTests.cs index ed1061e85a9..968e2397e5a 100644 --- a/tests/Avalonia.Base.UnitTests/Input/AccessKeyHandlerTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/AccessKeyHandlerTests.cs @@ -7,7 +7,7 @@ namespace Avalonia.Base.UnitTests.Input { - public class AccessKeyHandlerTests + public class AccessKeyHandlerTests : ScopedTestBase { [Fact] public void Should_Raise_Key_Events_For_Unregistered_Access_Key() diff --git a/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs b/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs index b4d20f2496a..224dceaf729 100644 --- a/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs +++ b/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs @@ -14,7 +14,7 @@ namespace Avalonia.Base.UnitTests.Input; -public abstract class PointerTestsBase +public abstract class PointerTestsBase : ScopedTestBase { private protected static void SetHit(Mock renderer, Control? hit) { diff --git a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_EffectiveViewportChanged.cs b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_EffectiveViewportChanged.cs index 09e4986da02..8ec87484f0e 100644 --- a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_EffectiveViewportChanged.cs +++ b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_EffectiveViewportChanged.cs @@ -11,7 +11,7 @@ namespace Avalonia.Base.UnitTests.Layout { - public class LayoutableTests_EffectiveViewportChanged + public class LayoutableTests_EffectiveViewportChanged : ScopedTestBase { [Fact] public async Task EffectiveViewportChanged_Not_Raised_When_Control_Added_To_Tree_And_Layout_Pass_Has_Not_Run() @@ -38,9 +38,7 @@ await RunOnUIThread.Execute(async () => [Fact] public async Task EffectiveViewportChanged_Raised_When_Control_Added_To_Tree_And_Layout_Pass_Has_Run() { -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously await RunOnUIThread.Execute(async () => -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { var root = CreateRoot(); var target = new Canvas(); @@ -64,9 +62,7 @@ await RunOnUIThread.Execute(async () => [Fact] public async Task EffectiveViewportChanged_Raised_When_Root_LayedOut_And_Then_Control_Added_To_Tree_And_Layout_Pass_Runs() { -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously await RunOnUIThread.Execute(async () => -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { var root = CreateRoot(); var target = new Canvas(); diff --git a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs index 77f1a8882d0..866d7cbb759 100644 --- a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs +++ b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs @@ -6,7 +6,7 @@ namespace Avalonia.Base.UnitTests.Layout { - public class LayoutableTests_LayoutRounding + public class LayoutableTests_LayoutRounding : ScopedTestBase { [Theory] [InlineData(100, 100)] @@ -112,7 +112,7 @@ private static void AssertEqual(Point expected, Point actual) { if (!expected.NearlyEquals(actual)) { - throw new EqualException(expected, actual); + throw EqualException.ForMismatchedValues(expected, actual); } } @@ -120,7 +120,7 @@ private static void AssertEqual(Size expected, Size actual) { if (!expected.NearlyEquals(actual)) { - throw new EqualException(expected, actual); + throw EqualException.ForMismatchedValues(expected, actual); } } diff --git a/tests/Avalonia.Base.UnitTests/Properties/AssemblyInfo.cs b/tests/Avalonia.Base.UnitTests/Properties/AssemblyInfo.cs index 7d45a770b0a..c3cffd360ab 100644 --- a/tests/Avalonia.Base.UnitTests/Properties/AssemblyInfo.cs +++ b/tests/Avalonia.Base.UnitTests/Properties/AssemblyInfo.cs @@ -1,7 +1,9 @@ using System.Reflection; +using Avalonia.UnitTests; using Xunit; -[assembly: AssemblyTitle("Avalonia.UnitTests")] +[assembly: AssemblyTitle("Avalonia.Base.UnitTests")] // Don't run tests in parallel. [assembly: CollectionBehavior(DisableTestParallelization = true)] +[assembly: VerifyEmptyDispatcherAfterTest] diff --git a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs index 22cafb403bf..413ce22e715 100644 --- a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs @@ -358,8 +358,10 @@ public void Close_Window_On_Alt_F4_When_ComboBox_Is_Focus() } [Fact] - public void FlowDirection_Of_RectangleContent_Shuold_Be_LeftToRight() + public void FlowDirection_Of_RectangleContent_Should_Be_LeftToRight() { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + var target = new ComboBox { FlowDirection = FlowDirection.RightToLeft, @@ -385,6 +387,8 @@ public void FlowDirection_Of_RectangleContent_Shuold_Be_LeftToRight() [Fact] public void FlowDirection_Of_RectangleContent_Updated_After_InvalidateMirrorTransform() { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + var parentContent = new Decorator() { Child = new Control() diff --git a/tests/Avalonia.Controls.UnitTests/Properties/AssemblyInfo.cs b/tests/Avalonia.Controls.UnitTests/Properties/AssemblyInfo.cs index 4a32235f924..3b191d375e2 100644 --- a/tests/Avalonia.Controls.UnitTests/Properties/AssemblyInfo.cs +++ b/tests/Avalonia.Controls.UnitTests/Properties/AssemblyInfo.cs @@ -1,7 +1,9 @@ using System.Reflection; +using Avalonia.UnitTests; using Xunit; [assembly: AssemblyTitle("Avalonia.Controls.UnitTests")] // Don't run tests in parallel. -[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file +[assembly: CollectionBehavior(DisableTestParallelization = true)] +[assembly: VerifyEmptyDispatcherAfterTest] diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index 4189ecd9de0..a102ca95f3d 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -408,6 +408,8 @@ public void SelectedContentTemplate_Updates_After_New_ContentTemplate() [Fact] public void Previous_ContentTemplate_Is_Not_Reused_When_TabItem_Changes() { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + int templatesBuilt = 0; var target = new TabControl diff --git a/tests/Avalonia.Headless.UnitTests/InputTests.cs b/tests/Avalonia.Headless.UnitTests/InputTests.cs index f96606b5bb4..841201e51e5 100644 --- a/tests/Avalonia.Headless.UnitTests/InputTests.cs +++ b/tests/Avalonia.Headless.UnitTests/InputTests.cs @@ -1,6 +1,7 @@ using System; using System.Reactive.Disposables; using System.Threading; +using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; @@ -35,12 +36,11 @@ public InputTests() #if NUNIT [AvaloniaTest, Timeout(10000)] #elif XUNIT - [AvaloniaFact(Timeout = 10000)] + [AvaloniaFact] #endif public void Should_Click_Button_On_Window() { Assert.True(_setupApp == Application.Current); - var buttonClicked = false; var button = new Button { @@ -62,7 +62,7 @@ public void Should_Click_Button_On_Window() #if NUNIT [AvaloniaTest, Timeout(10000)] #elif XUNIT - [AvaloniaFact(Timeout = 10000)] + [AvaloniaFact] #endif public void Change_Window_Position() { diff --git a/tests/Avalonia.Headless.UnitTests/RenderingTests.cs b/tests/Avalonia.Headless.UnitTests/RenderingTests.cs index 74afb158658..7bb9e1c5b04 100644 --- a/tests/Avalonia.Headless.UnitTests/RenderingTests.cs +++ b/tests/Avalonia.Headless.UnitTests/RenderingTests.cs @@ -14,7 +14,7 @@ public class RenderingTests #if NUNIT [AvaloniaTest, Timeout(10000)] #elif XUNIT - [AvaloniaFact(Timeout = 10000)] + [AvaloniaFact] #endif public void Should_Render_Last_Frame_To_Bitmap() { @@ -43,7 +43,7 @@ public void Should_Render_Last_Frame_To_Bitmap() #if NUNIT [AvaloniaTest, Timeout(10000)] #elif XUNIT - [AvaloniaFact(Timeout = 10000)] + [AvaloniaFact] #endif public void Should_Not_Crash_On_GeometryGroup() { @@ -79,7 +79,7 @@ public void Should_Not_Crash_On_GeometryGroup() #if NUNIT [AvaloniaTest, Timeout(10000)] #elif XUNIT - [AvaloniaFact(Timeout = 10000)] + [AvaloniaFact] #endif public void Should_Not_Crash_On_CombinedGeometry() { @@ -110,7 +110,7 @@ public void Should_Not_Crash_On_CombinedGeometry() #if NUNIT [AvaloniaTest, Timeout(10000)] #elif XUNIT - [AvaloniaFact(Timeout = 10000)] + [AvaloniaFact] #endif public void Should_Not_Hang_With_Non_Trivial_Layout() { diff --git a/tests/Avalonia.Headless.UnitTests/ServicesTests.cs b/tests/Avalonia.Headless.UnitTests/ServicesTests.cs index 251fe86ff56..8a6b03cf15a 100644 --- a/tests/Avalonia.Headless.UnitTests/ServicesTests.cs +++ b/tests/Avalonia.Headless.UnitTests/ServicesTests.cs @@ -13,7 +13,7 @@ public class ServicesTests #if NUNIT [AvaloniaTest, Timeout(10000)] #elif XUNIT - [AvaloniaFact(Timeout = 10000)] + [AvaloniaFact] #endif public void Can_Access_Screens() { diff --git a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs index 9d5ef6e500b..4becb43b3b1 100644 --- a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs +++ b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs @@ -12,7 +12,7 @@ public class ThreadingTests #if NUNIT [AvaloniaTest, Timeout(10000)] #elif XUNIT - [AvaloniaFact(Timeout = 10000)] + [AvaloniaFact] #endif public void Should_Be_On_Dispatcher_Thread() { diff --git a/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs b/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs index 1d7d767fb27..53b74f9694b 100644 --- a/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs +++ b/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs @@ -411,16 +411,16 @@ private static void AssertCloseEnough(PixelPoint expected, PixelPoint actual) // the position of a centered window can be off by a bit. From initial testing, looks // like this shouldn't be more than 10 pixels. if (Math.Abs(expected.X - actual.X) > 10) - throw new EqualException(expected, actual); + throw EqualException.ForMismatchedValues(expected, actual); if (Math.Abs(expected.Y - actual.Y) > 10) - throw new EqualException(expected, actual); + throw EqualException.ForMismatchedValues(expected, actual); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { if (Math.Abs(expected.X - actual.X) > 15) - throw new EqualException(expected, actual); + throw EqualException.ForMismatchedValues(expected, actual); if (Math.Abs(expected.Y - actual.Y) > 15) - throw new EqualException(expected, actual); + throw EqualException.ForMismatchedValues(expected, actual); } else { diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj index 4c0217bcc7b..bd63ddd4968 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj @@ -37,11 +37,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Avalonia.ReactiveUI.UnitTests/Attributes.cs b/tests/Avalonia.ReactiveUI.UnitTests/Attributes.cs index 79e58f63e94..6d0d1f04886 100644 --- a/tests/Avalonia.ReactiveUI.UnitTests/Attributes.cs +++ b/tests/Avalonia.ReactiveUI.UnitTests/Attributes.cs @@ -1,6 +1,8 @@ +using Avalonia.UnitTests; using Xunit; // Required to avoid InvalidOperationException sometimes thrown // from Splat.MemoizingMRUCache.cs which is not thread-safe. // Thrown when trying to access WhenActivated concurrently. -[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file +[assembly: CollectionBehavior(DisableTestParallelization = true)] +[assembly: VerifyEmptyDispatcherAfterTest] diff --git a/tests/Avalonia.ReactiveUI.UnitTests/ReactiveUserControlTest.cs b/tests/Avalonia.ReactiveUI.UnitTests/ReactiveUserControlTest.cs index 67790789e31..d744f2ccacc 100644 --- a/tests/Avalonia.ReactiveUI.UnitTests/ReactiveUserControlTest.cs +++ b/tests/Avalonia.ReactiveUI.UnitTests/ReactiveUserControlTest.cs @@ -8,7 +8,7 @@ namespace Avalonia.ReactiveUI.UnitTests { - public class ReactiveUserControlTest + public class ReactiveUserControlTest : ScopedTestBase { public class ExampleViewModel : ReactiveObject, IActivatableViewModel { diff --git a/tests/Avalonia.UnitTests/InvariantCultureAttribute.cs b/tests/Avalonia.UnitTests/InvariantCultureAttribute.cs index b21e364c1e7..d24acd23ec6 100644 --- a/tests/Avalonia.UnitTests/InvariantCultureAttribute.cs +++ b/tests/Avalonia.UnitTests/InvariantCultureAttribute.cs @@ -15,7 +15,7 @@ namespace Avalonia.UnitTests; /// Some tests are formatting numbers, expecting a dot as a decimal point. /// Use this fixture to set the current culture to the invariant culture. /// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] public sealed class InvariantCultureAttribute : BeforeAfterTestAttribute { private CultureInfo? _previousCulture; diff --git a/tests/Avalonia.UnitTests/VerifyEmptyDispatcherAfterTestAttribute.cs b/tests/Avalonia.UnitTests/VerifyEmptyDispatcherAfterTestAttribute.cs new file mode 100644 index 00000000000..c4c0d299a5a --- /dev/null +++ b/tests/Avalonia.UnitTests/VerifyEmptyDispatcherAfterTestAttribute.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Threading; +using Xunit; +using Xunit.Sdk; + +namespace Avalonia.UnitTests; + +public sealed class VerifyEmptyDispatcherAfterTestAttribute : BeforeAfterTestAttribute +{ + public override void After(MethodInfo methodUnderTest) + { + if (typeof(ScopedTestBase).IsAssignableFrom(methodUnderTest.DeclaringType)) + return; + + var dispatcher = Dispatcher.UIThread; + var jobs = dispatcher.GetJobs(); + if (jobs.Count == 0) + return; + + dispatcher.ClearJobs(); + + // Ignore the Control.Loaded callback. It might happen synchronously or might be posted. + if (jobs.Count == 1 && IsLoadedCallback(jobs[0])) + return; + + Assert.Fail( + $"The test left {jobs.Count} unprocessed dispatcher {(jobs.Count == 1 ? "job" : "jobs")}:\n" + + $"{string.Join(Environment.NewLine, jobs.Select(job => $" - {job.DebugDisplay}"))}\n" + + $"Consider using ScopedTestBase or UnitTestApplication.Start()."); + + static bool IsLoadedCallback(DispatcherOperation job) + => job.Priority == DispatcherPriority.Loaded && + (job.Callback as Delegate)?.Method.DeclaringType?.DeclaringType == typeof(Control); + } +}