From 06189f77d43347ee743fc0e8f6343083c5b5c800 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 26 Apr 2023 17:23:04 -0400 Subject: [PATCH 01/40] Add netstandard2.0 to the headless XUnit integration --- .../Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj | 4 +--- .../Avalonia.Headless.XUnit/AvaloniaTestFramework.cs | 3 ++- .../AvaloniaTestFrameworkAttribute.cs | 6 +++++- src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs | 6 +++++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj index c2c58b4f94..71c68b108a 100644 --- a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj +++ b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj @@ -1,8 +1,6 @@ - net6.0 - enable - enable + netstandard2.0;net6.0 diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs index 21086fa946..44505f9175 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Collections.Generic; +using System.Reflection; using Xunit.Abstractions; using Xunit.Sdk; diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs index 3eace30805..83b4b4c8aa 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs @@ -1,4 +1,6 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using Xunit.Abstractions; using Xunit.Sdk; @@ -19,7 +21,9 @@ public sealed class AvaloniaTestFrameworkAttribute : Attribute, ITestFrameworkAt /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. /// public AvaloniaTestFrameworkAttribute( +#if NET6_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] +#endif Type appBuilderEntryPointType) { } } diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs index 42604adf46..602a94e0fd 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs @@ -1,4 +1,8 @@ -using Avalonia.Threading; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; using Xunit.Abstractions; using Xunit.Sdk; From d59727e1047ef8aa44d21c14755fbf7e4a383884 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 00:32:45 -0400 Subject: [PATCH 02/40] Implement AvaloniaFact and AvaloniaTheory attributes, introduce HeadlessUnitTestSession abstraction --- .../Avalonia.Controls.csproj | 1 - .../Avalonia.Headless.XUnit.csproj | 3 +- .../Avalonia.Headless.XUnit/AvaloniaFact.cs | 35 +++++ .../AvaloniaTestCase.cs | 49 ++++++ .../AvaloniaTestFramework.cs | 5 +- .../AvaloniaTestFrameworkAttribute.cs | 19 +-- .../AvaloniaTestRunner.cs | 45 +----- .../AvaloniaTheoryAttribute.cs | 37 +++++ .../AvaloniaTheoryTestCase.cs | 36 +++++ .../AvaloniaTestApplicationAttribute.cs | 27 ++++ .../HeadlessUnitTestSession.cs | 141 ++++++++++++++++++ 11 files changed, 340 insertions(+), 58 deletions(-) create mode 100644 src/Headless/Avalonia.Headless.XUnit/AvaloniaFact.cs create mode 100644 src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs create mode 100644 src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryAttribute.cs create mode 100644 src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs create mode 100644 src/Headless/Avalonia.Headless/AvaloniaTestApplicationAttribute.cs create mode 100644 src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs diff --git a/src/Avalonia.Controls/Avalonia.Controls.csproj b/src/Avalonia.Controls/Avalonia.Controls.csproj index 9c4bacbedf..42e965409a 100644 --- a/src/Avalonia.Controls/Avalonia.Controls.csproj +++ b/src/Avalonia.Controls/Avalonia.Controls.csproj @@ -17,7 +17,6 @@ - diff --git a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj index 71c68b108a..df5822dc01 100644 --- a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj +++ b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj @@ -4,7 +4,8 @@ - + + diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaFact.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaFact.cs new file mode 100644 index 0000000000..f501fc7a56 --- /dev/null +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaFact.cs @@ -0,0 +1,35 @@ +using System; +using System.ComponentModel; +using System.Threading; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Avalonia.Headless.XUnit; + +/// +/// Identifies an xunit test that starts on Avalonia Dispatcher +/// such that awaited expressions resume on the test's "main thread". +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +[XunitTestCaseDiscoverer("Avalonia.Headless.XUnit.AvaloniaUIFactDiscoverer", "Avalonia.Headless.XUnit")] +public sealed class AvaloniaFactAttribute : FactAttribute +{ + +} + +[EditorBrowsable(EditorBrowsableState.Never)] +public class AvaloniaUIFactDiscoverer : FactDiscoverer +{ + private readonly IMessageSink diagnosticMessageSink; + public AvaloniaUIFactDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + this.diagnosticMessageSink = diagnosticMessageSink; + } + + protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + return new AvaloniaTestCase(diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod); + } +} diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs new file mode 100644 index 0000000000..fd124aec75 --- /dev/null +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs @@ -0,0 +1,49 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Avalonia.Headless.XUnit; + +internal class AvaloniaTestCase : XunitTestCase +{ + public AvaloniaTestCase( + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + ITestMethod testMethod, + object?[]? testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, TestMethodDisplayOptions.None, testMethod, testMethodArguments) + { + } + + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public AvaloniaTestCase() + { + } + + public override Task RunAsync( + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + { + var session = HeadlessUnitTestSession.GetOrStartForAssembly(Method.ToRuntimeMethod().DeclaringType?.Assembly); + + var task = session.Dispatcher.InvokeAsync>(async () => + { + var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, + TestMethodArguments, messageBus, aggregator, cancellationTokenSource); + return await runner.RunAsync(); + }, default, cancellationTokenSource.Token).GetTask().Unwrap(); + + // We need to block the XUnit thread to ensure its concurrency throttle is effective. + // See https://github.com/AArnott/Xunit.StaFact/pull/55#issuecomment-826187354 for details. + var runSummary = task.GetAwaiter().GetResult(); + return Task.FromResult(runSummary); + } +} diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs index 44505f9175..2d45228c57 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs @@ -5,7 +5,7 @@ using Xunit.Sdk; namespace Avalonia.Headless.XUnit; -internal class AvaloniaTestFramework : XunitTestFramework +internal class AvaloniaTestFramework : XunitTestFramework { public AvaloniaTestFramework(IMessageSink messageSink) : base(messageSink) { @@ -27,8 +27,7 @@ internal class AvaloniaTestFramework : XunitTestFramework IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) { - executionOptions.SetValue("xunit.execution.DisableParallelization", false); - using (var assemblyRunner = new AvaloniaTestRunner( + using (var assemblyRunner = new AvaloniaTestRunner( TestAssembly, testCases, DiagnosticMessageSink, executionMessageSink, executionOptions)) await assemblyRunner.RunAsync(); } diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs index 83b4b4c8aa..bdd8f3b0ea 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs @@ -9,22 +9,13 @@ namespace Avalonia.Headless.XUnit; /// /// Sets up global avalonia test framework using avalonia application builder passed as a parameter. /// +/// +/// It is an alternative to using [AvaloniaFact] or [AvaloniaTheory] attributes on every test method. +/// [TestFrameworkDiscoverer("Avalonia.Headless.XUnit.AvaloniaTestFrameworkTypeDiscoverer", "Avalonia.Headless.XUnit")] [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] public sealed class AvaloniaTestFrameworkAttribute : Attribute, ITestFrameworkAttribute { - /// - /// Creates instance of . - /// - /// - /// Parameter from which should be created. - /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. - /// - public AvaloniaTestFrameworkAttribute( -#if NET6_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] -#endif - Type appBuilderEntryPointType) { } } /// @@ -42,8 +33,6 @@ public class AvaloniaTestFrameworkTypeDiscoverer : ITestFrameworkTypeDiscoverer /// public Type GetTestFrameworkType(IAttributeInfo attribute) { - var builderType = attribute.GetConstructorArguments().First() as Type - ?? throw new InvalidOperationException("AppBuilderEntryPointType parameter must be defined on the AvaloniaTestFrameworkAttribute attribute."); - return typeof(AvaloniaTestFramework<>).MakeGenericType(builderType); + return typeof(AvaloniaTestFramework); } } diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs index 602a94e0fd..86efe33f5c 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; @@ -8,10 +9,10 @@ using Xunit.Sdk; namespace Avalonia.Headless.XUnit; -internal class AvaloniaTestRunner : XunitTestAssemblyRunner +internal class AvaloniaTestRunner : XunitTestAssemblyRunner { - private CancellationTokenSource? _cancellationTokenSource; - + private HeadlessUnitTestSession? _session; + public AvaloniaTestRunner(ITestAssembly testAssembly, IEnumerable testCases, IMessageSink diagnosticMessageSink, IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) : base(testAssembly, testCases, diagnosticMessageSink, @@ -21,45 +22,13 @@ internal class AvaloniaTestRunner : XunitTestAssemblyRunner protected override void SetupSyncContext(int maxParallelThreads) { - _cancellationTokenSource?.Dispose(); - _cancellationTokenSource = new CancellationTokenSource(); - SynchronizationContext.SetSynchronizationContext(InitNewApplicationContext(_cancellationTokenSource.Token).Result); + _session = HeadlessUnitTestSession.GetOrStartForAssembly(Assembly.Load(new AssemblyName(TestAssembly.Assembly.Name))); + SynchronizationContext.SetSynchronizationContext(_session.SynchronizationContext); } public override void Dispose() { - _cancellationTokenSource?.Cancel(); + _session?.Dispose(); base.Dispose(); } - - internal static Task InitNewApplicationContext(CancellationToken cancellationToken) - { - var tcs = new TaskCompletionSource(); - - new Thread(() => - { - try - { - var appBuilder = AppBuilder.Configure(typeof(TAppBuilderEntry)); - - // If windowing subsystem wasn't initialized by user, force headless with default parameters. - if (appBuilder.WindowingSubsystemName != "Headless") - { - appBuilder = appBuilder.UseHeadless(new AvaloniaHeadlessPlatformOptions()); - } - - appBuilder.SetupWithoutStarting(); - - tcs.SetResult(SynchronizationContext.Current!); - } - catch (Exception e) - { - tcs.SetException(e); - } - - Dispatcher.UIThread.MainLoop(cancellationToken); - }) { IsBackground = true }.Start(); - - return tcs.Task; - } } diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryAttribute.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryAttribute.cs new file mode 100644 index 0000000000..53c997f08f --- /dev/null +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryAttribute.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Avalonia.Headless.XUnit; + +/// +/// Identifies an xunit theory that starts on Avalonia Dispatcher +/// such that awaited expressions resume on the test's "main thread". +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +[XunitTestCaseDiscoverer("Avalonia.Headless.XUnit.AvaloniaTheoryDiscoverer", "Avalonia.Headless.XUnit")] +public sealed class AvaloniaTheoryAttribute : TheoryAttribute +{ +} + +[EditorBrowsable(EditorBrowsableState.Never)] +public class AvaloniaTheoryDiscoverer : TheoryDiscoverer +{ + public AvaloniaTheoryDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + } + + protected override IEnumerable CreateTestCasesForDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow) + { + yield return new AvaloniaTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod, dataRow); + } + + protected override IEnumerable CreateTestCasesForTheory(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute) + { + yield return new AvaloniaTheoryTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod); + } +} diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs new file mode 100644 index 0000000000..454fe48e07 --- /dev/null +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs @@ -0,0 +1,36 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Avalonia.Headless.XUnit; + +internal class AvaloniaTheoryTestCase : XunitTheoryTestCase +{ + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public AvaloniaTheoryTestCase() + { + } + + public AvaloniaTheoryTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod) + { + } + + public override async Task RunAsync(IMessageSink diagnosticMessageSink, IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) + { + var session = HeadlessUnitTestSession.GetOrStartForAssembly(Method.ToRuntimeMethod().DeclaringType?.Assembly); + + var task = session.Dispatcher.InvokeAsync>(async () => + { + var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, + TestMethodArguments, messageBus, aggregator, cancellationTokenSource); + return await runner.RunAsync(); + }, default, cancellationTokenSource.Token).GetTask().Unwrap(); + + return await task; + } +} diff --git a/src/Headless/Avalonia.Headless/AvaloniaTestApplicationAttribute.cs b/src/Headless/Avalonia.Headless/AvaloniaTestApplicationAttribute.cs new file mode 100644 index 0000000000..9159657ec4 --- /dev/null +++ b/src/Headless/Avalonia.Headless/AvaloniaTestApplicationAttribute.cs @@ -0,0 +1,27 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Avalonia.Headless; + +/// +/// Sets up global avalonia test framework using avalonia application builder passed as a parameter. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +public sealed class AvaloniaTestApplicationAttribute : Attribute +{ + public Type AppBuilderEntryPointType { get; } + + /// + /// Creates instance of . + /// + /// + /// Parameter from which should be created. + /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. + /// + public AvaloniaTestApplicationAttribute( + [DynamicallyAccessedMembers(HeadlessUnitTestSession.DynamicallyAccessed)] + Type appBuilderEntryPointType) + { + AppBuilderEntryPointType = appBuilderEntryPointType; + } +} diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs new file mode 100644 index 0000000000..3339d9a48c --- /dev/null +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -0,0 +1,141 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace Avalonia.Headless; + +/// +/// Headless unit test session that needs to be used by the actual testing framework. +/// All UI tests are supposed to be executed from the or +/// to keep execution flow on the UI thread. +/// Disposing unit test session stops internal dispatcher loop. +/// +/// +/// As Avalonia supports only a single Application instance created, this session must be created only once as well. +/// +public sealed class HeadlessUnitTestSession : IDisposable +{ + private readonly CancellationTokenSource _cancellationToken; + private static HeadlessUnitTestSession? s_session; + private static object s_lock = new(); + + internal const DynamicallyAccessedMemberTypes DynamicallyAccessed = + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; + + private HeadlessUnitTestSession(Type entryPointType, Application application, + SynchronizationContext synchronizationContext, + Dispatcher dispatcher, CancellationTokenSource cancellationToken) + { + _cancellationToken = cancellationToken; + EntryPointType = entryPointType; + Dispatcher = dispatcher; + Application = application; + SynchronizationContext = synchronizationContext; + } + + public Application Application { get; } + public SynchronizationContext SynchronizationContext { get; } + public Dispatcher Dispatcher { get; } + internal Type EntryPointType { get; } + + public void Dispose() + { + _cancellationToken.Cancel(); + } + + /// + /// Creates instance of . + /// + /// + /// Parameter from which should be created. + /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. + /// + public static Task StartNew< + [DynamicallyAccessedMembers(DynamicallyAccessed)] TEntryPointType>() + { + return StartNew(typeof(TEntryPointType)); + } + + /// + /// Creates instance of . + /// + /// + /// Parameter from which should be created. + /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. + /// + public static Task StartNew( + [DynamicallyAccessedMembers(DynamicallyAccessed)] Type entryPointType) + { + var tcs = new TaskCompletionSource(); + var cancellationTokenSource = new CancellationTokenSource(); + + Thread? thread = null; + thread = new Thread(() => + { + try + { + var appBuilder = AppBuilder.Configure(entryPointType); + + // If windowing subsystem wasn't initialized by user, force headless with default parameters. + if (appBuilder.WindowingSubsystemName != "Headless") + { + appBuilder = appBuilder.UseHeadless(new AvaloniaHeadlessPlatformOptions()); + } + + appBuilder.SetupWithoutStarting(); + + // ReSharper disable once AccessToModifiedClosure + tcs.SetResult(new HeadlessUnitTestSession(entryPointType, Application.Current!, + SynchronizationContext.Current!, Dispatcher.UIThread, cancellationTokenSource)); + } + catch (Exception e) + { + tcs.SetException(e); + } + + Dispatcher.UIThread.MainLoop(cancellationTokenSource.Token); + }) { IsBackground = true }; + thread.Start(); + + return tcs.Task; + } + + /// + /// Creates a session from AvaloniaTestApplicationAttribute attribute or reuses any existing. + /// If AvaloniaTestApplicationAttribute doesn't exist, empty application is used. + /// + /// + /// Note, only single session can be crated per app execution. + /// + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "AvaloniaTestApplicationAttribute attribute should preserve type information.")] + public static HeadlessUnitTestSession GetOrStartForAssembly(Assembly? assembly) + { + lock (s_lock) + { + var appBuilderEntryPointType = assembly?.GetCustomAttribute() + ?.AppBuilderEntryPointType; + + if (s_session is not null) + { + if (appBuilderEntryPointType != s_session.EntryPointType) + { + // Avalonia doesn't support multiple Application instances. At least at the moment. + throw new System.InvalidOperationException( + "AvaloniaTestApplicationAttribute must be defined only once per single unit tests session."); + } + + return s_session; + } + + + s_session = appBuilderEntryPointType is not null ? StartNew(appBuilderEntryPointType).Result : StartNew(typeof(Application)).Result; + + return s_session; + } + } +} From 2da88e7cfcee9f7bbae8640d6fdd78c3271411de Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 00:33:04 -0400 Subject: [PATCH 03/40] Implement Avalonia.Headless.NUnit integration --- Avalonia.sln | 26 ++++++++--- .../Avalonia.Headless.NUnit.csproj | 18 ++++++++ .../Avalonia.Headless.NUnit/AvaloniaTest.cs | 25 +++++++++++ .../AvaloniaTestCommand.cs | 44 +++++++++++++++++++ .../Avalonia.Headless.NUnit/AvaloniaTheory.cs | 24 ++++++++++ .../Avalonia.Headless.NUnit/ExecutionQueue.cs | 35 +++++++++++++++ 6 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj create mode 100644 src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs create mode 100644 src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs create mode 100644 src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs create mode 100644 src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs diff --git a/Avalonia.sln b/Avalonia.sln index d4ccdfdc69..b4148f9337 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -264,7 +264,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Headless", "Headless", "{FF EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.XUnit", "src\Headless\Avalonia.Headless.XUnit\Avalonia.Headless.XUnit.csproj", "{F47F8316-4D4B-4026-8EF3-16B2CFDA8119}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.UnitTests", "tests\Avalonia.Headless.UnitTests\Avalonia.Headless.UnitTests.csproj", "{3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.NUnit", "src\Headless\Avalonia.Headless.NUnit\Avalonia.Headless.NUnit.csproj", "{ED976634-B118-43F8-8B26-0279C7A7044F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.XUnit.UnitTests", "tests\Avalonia.Headless.XUnit.UnitTests\Avalonia.Headless.XUnit.UnitTests.csproj", "{EBA7613E-C36C-4E0C-AB45-71B143F86219}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.NUnit.UnitTests", "tests\Avalonia.Headless.NUnit.UnitTests\Avalonia.Headless.NUnit.UnitTests.csproj", "{47025FBC-2130-42EE-98C9-D3989B3B9446}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -609,10 +613,6 @@ Global {F47F8316-4D4B-4026-8EF3-16B2CFDA8119}.Debug|Any CPU.Build.0 = Debug|Any CPU {F47F8316-4D4B-4026-8EF3-16B2CFDA8119}.Release|Any CPU.ActiveCfg = Release|Any CPU {F47F8316-4D4B-4026-8EF3-16B2CFDA8119}.Release|Any CPU.Build.0 = Release|Any CPU - {3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}.Release|Any CPU.Build.0 = Release|Any CPU {A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Debug|Any CPU.Build.0 = Debug|Any CPU {A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -637,6 +637,18 @@ Global {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.ActiveCfg = Release|Any CPU {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.Build.0 = Release|Any CPU {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.Deploy.0 = Release|Any CPU + {ED976634-B118-43F8-8B26-0279C7A7044F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED976634-B118-43F8-8B26-0279C7A7044F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED976634-B118-43F8-8B26-0279C7A7044F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED976634-B118-43F8-8B26-0279C7A7044F}.Release|Any CPU.Build.0 = Release|Any CPU + {EBA7613E-C36C-4E0C-AB45-71B143F86219}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EBA7613E-C36C-4E0C-AB45-71B143F86219}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBA7613E-C36C-4E0C-AB45-71B143F86219}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EBA7613E-C36C-4E0C-AB45-71B143F86219}.Release|Any CPU.Build.0 = Release|Any CPU + {47025FBC-2130-42EE-98C9-D3989B3B9446}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47025FBC-2130-42EE-98C9-D3989B3B9446}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47025FBC-2130-42EE-98C9-D3989B3B9446}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47025FBC-2130-42EE-98C9-D3989B3B9446}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -707,7 +719,6 @@ Global {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC} = {FF237916-7150-496B-89ED-6CA3292896E7} {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E} = {FF237916-7150-496B-89ED-6CA3292896E7} {F47F8316-4D4B-4026-8EF3-16B2CFDA8119} = {FF237916-7150-496B-89ED-6CA3292896E7} - {3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {DDA28789-C21A-4654-86CE-D01E81F095C5} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} {2D7C812B-7E73-4252-8EFD-BC8A4D5CCB9F} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {A82AD1BC-EBE6-4FC3-A13B-D52A50297533} = {9B9E3891-2366-4253-A952-D08BCEB71098} @@ -715,6 +726,9 @@ Global {22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D} = {9B9E3891-2366-4253-A952-D08BCEB71098} {4CDAD037-34A2-4CCF-A03A-C6C7B988A572} = {9B9E3891-2366-4253-A952-D08BCEB71098} {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {ED976634-B118-43F8-8B26-0279C7A7044F} = {FF237916-7150-496B-89ED-6CA3292896E7} + {EBA7613E-C36C-4E0C-AB45-71B143F86219} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} + {47025FBC-2130-42EE-98C9-D3989B3B9446} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj b/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj new file mode 100644 index 0000000000..3b8950d5d1 --- /dev/null +++ b/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj @@ -0,0 +1,18 @@ + + + netstandard2.0;net6.0 + + + + + + + + + + + + + + + diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs new file mode 100644 index 0000000000..fd04146391 --- /dev/null +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs @@ -0,0 +1,25 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading; +using NUnit.Framework; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal.Commands; + +namespace Avalonia.Headless.NUnit; + +/// +/// Identifies a nunit test that starts on Avalonia Dispatcher +/// such that awaited expressions resume on the test's "main thread". +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class AvaloniaTestAttribute : TestCaseAttribute, IWrapSetUpTearDown +{ + public TestCommand Wrap(TestCommand command) + { + var session = + HeadlessUnitTestSession.GetOrStartForAssembly(command.Test.Method?.MethodInfo.DeclaringType?.Assembly); + + return new AvaloniaTestCommand(session, command); + } +} diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs new file mode 100644 index 0000000000..e5eabb612a --- /dev/null +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal; +using NUnit.Framework.Internal.Commands; + +namespace Avalonia.Headless.NUnit; + +internal class AvaloniaTestCommand : DelegatingTestCommand +{ + private readonly HeadlessUnitTestSession _session; + + public AvaloniaTestCommand(HeadlessUnitTestSession session, TestCommand innerCommand) + : base(innerCommand) + { + _session = session; + } + + public override TestResult Execute(TestExecutionContext context) + { + return _session.Dispatcher.InvokeAsync>(async () => + { + var testMethod = innerCommand.Test.Method; + var methodInfo = testMethod!.MethodInfo; + + var result = methodInfo.Invoke(context.TestObject, innerCommand.Test.Arguments); + // Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks. + if (result is Task task) + { + await task; + } + else if (result is ValueTask valueTask) + { + await valueTask; + } + + context.CurrentResult.SetResult(ResultState.Success); + + if (context.CurrentResult.AssertionResults.Count > 0) + context.CurrentResult.RecordTestCompletion(); + + return context.CurrentResult; + }).GetTask().Unwrap().Result; + } +} diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs new file mode 100644 index 0000000000..1f3cadd296 --- /dev/null +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs @@ -0,0 +1,24 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading; +using NUnit.Framework; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal.Commands; + +namespace Avalonia.Headless.NUnit; + +/// +/// Identifies a nunit theory that starts on Avalonia Dispatcher +/// such that awaited expressions resume on the test's "main thread". +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class AvaloniaTheoryAttribute : TheoryAttribute, IWrapSetUpTearDown +{ + public TestCommand Wrap(TestCommand command) + { + var session = HeadlessUnitTestSession.GetOrStartForAssembly(command.Test.Method?.MethodInfo.DeclaringType?.Assembly); + + return new AvaloniaTestCommand(session, command); + } +} diff --git a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs new file mode 100644 index 0000000000..5488fc956a --- /dev/null +++ b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace Avalonia.Headless.NUnit; + +internal static class ExecutionQueue +{ + static bool _running; + static Queue> _queue = new(); + static async void TryExecuteNext() + { + if (_running || _queue.Count == 0) return; + try + { + _running = true; + await _queue.Dequeue()(); + } + finally + { + _running = false; + } + TryExecuteNext(); + } + + static void ExecuteOnQueue(this Dispatcher dispatcher, Func cb) + { + dispatcher.Post(() => + { + _queue.Enqueue(cb); + TryExecuteNext(); + }); + } +} From 8e4b76cb19c788802bca93c3c4d5e6f329f48fa1 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 00:33:12 -0400 Subject: [PATCH 04/40] Reuse same tests for XUnit and NUnit --- .../AssemblyInfo.cs | 9 ++++++ .../Avalonia.Headless.NUnit.UnitTests.csproj | 29 +++++++++++++++++++ .../Avalonia.Headless.UnitTests/InputTests.cs | 3 +- .../RenderingTests.cs | 3 +- .../TestApplication.cs | 5 ---- .../ThreadingTests.cs | 20 ++++++++----- .../AssemblyInfo.cs | 7 +++++ .../Avalonia.Headless.XUnit.UnitTests.csproj} | 7 ++++- 8 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 tests/Avalonia.Headless.NUnit.UnitTests/AssemblyInfo.cs create mode 100644 tests/Avalonia.Headless.NUnit.UnitTests/Avalonia.Headless.NUnit.UnitTests.csproj create mode 100644 tests/Avalonia.Headless.XUnit.UnitTests/AssemblyInfo.cs rename tests/{Avalonia.Headless.UnitTests/Avalonia.Headless.UnitTests.csproj => Avalonia.Headless.XUnit.UnitTests/Avalonia.Headless.XUnit.UnitTests.csproj} (83%) diff --git a/tests/Avalonia.Headless.NUnit.UnitTests/AssemblyInfo.cs b/tests/Avalonia.Headless.NUnit.UnitTests/AssemblyInfo.cs new file mode 100644 index 0000000000..1d5b99eb81 --- /dev/null +++ b/tests/Avalonia.Headless.NUnit.UnitTests/AssemblyInfo.cs @@ -0,0 +1,9 @@ +global using NUnit.Framework; +global using AvaloniaFactAttribute = Avalonia.Headless.NUnit.AvaloniaTestAttribute; +global using AvaloniaTheoryAttribute = Avalonia.Headless.NUnit.AvaloniaTheoryAttribute; +global using InlineDataAttribute = NUnit.Framework.TestCaseAttribute; + +using Avalonia.Headless; +using Avalonia.Headless.UnitTests; + +[assembly: AvaloniaTestApplication(typeof(TestApplication))] diff --git a/tests/Avalonia.Headless.NUnit.UnitTests/Avalonia.Headless.NUnit.UnitTests.csproj b/tests/Avalonia.Headless.NUnit.UnitTests/Avalonia.Headless.NUnit.UnitTests.csproj new file mode 100644 index 0000000000..19a82cb21c --- /dev/null +++ b/tests/Avalonia.Headless.NUnit.UnitTests/Avalonia.Headless.NUnit.UnitTests.csproj @@ -0,0 +1,29 @@ + + + net6.0 + true + $(DefineConstants);NUNIT + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Avalonia.Headless.UnitTests/InputTests.cs b/tests/Avalonia.Headless.UnitTests/InputTests.cs index 3c0ecbfdb7..3fcd509fe2 100644 --- a/tests/Avalonia.Headless.UnitTests/InputTests.cs +++ b/tests/Avalonia.Headless.UnitTests/InputTests.cs @@ -2,13 +2,12 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Threading; -using Xunit; namespace Avalonia.Headless.UnitTests; public class InputTests { - [Fact] + [AvaloniaFact] public void Should_Click_Button_On_Window() { var buttonClicked = false; diff --git a/tests/Avalonia.Headless.UnitTests/RenderingTests.cs b/tests/Avalonia.Headless.UnitTests/RenderingTests.cs index bc50686235..87fdd4ffbb 100644 --- a/tests/Avalonia.Headless.UnitTests/RenderingTests.cs +++ b/tests/Avalonia.Headless.UnitTests/RenderingTests.cs @@ -2,13 +2,12 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; -using Xunit; namespace Avalonia.Headless.UnitTests; public class RenderingTests { - [Fact] + [AvaloniaFact] public void Should_Render_Last_Frame_To_Bitmap() { var window = new Window diff --git a/tests/Avalonia.Headless.UnitTests/TestApplication.cs b/tests/Avalonia.Headless.UnitTests/TestApplication.cs index 7bfa0144f3..ae923bf52e 100644 --- a/tests/Avalonia.Headless.UnitTests/TestApplication.cs +++ b/tests/Avalonia.Headless.UnitTests/TestApplication.cs @@ -1,10 +1,5 @@ using Avalonia.Headless.UnitTests; -using Avalonia.Headless.XUnit; using Avalonia.Themes.Simple; -using Xunit; - -[assembly: AvaloniaTestFramework(typeof(TestApplication))] -[assembly: CollectionBehavior(DisableTestParallelization = true)] namespace Avalonia.Headless.UnitTests; diff --git a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs index 419ee5519e..e26b902eb1 100644 --- a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs +++ b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs @@ -2,31 +2,37 @@ using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; -using Xunit; namespace Avalonia.Headless.UnitTests; public class ThreadingTests { - [Fact] + [AvaloniaFact] public void Should_Be_On_Dispatcher_Thread() { Dispatcher.UIThread.VerifyAccess(); } - [Fact] - public async Task DispatcherTimer_Works_On_The_Same_Thread() + [AvaloniaTheory] + [InlineData(1)] + [InlineData(10)] + [InlineData(100)] + public async Task DispatcherTimer_Works_On_The_Same_Thread(int interval) { + await Task.Delay(100); + var currentThread = Thread.CurrentThread; var tcs = new TaskCompletionSource(); + var hasCompleted = false; DispatcherTimer.RunOnce(() => { - Assert.Equal(currentThread, Thread.CurrentThread); + hasCompleted = currentThread == Thread.CurrentThread; tcs.SetResult(); - }, TimeSpan.FromTicks(1)); + }, TimeSpan.FromTicks(interval)); - await tcs.Task; + await tcs.Task; + Assert.True(hasCompleted); } } diff --git a/tests/Avalonia.Headless.XUnit.UnitTests/AssemblyInfo.cs b/tests/Avalonia.Headless.XUnit.UnitTests/AssemblyInfo.cs new file mode 100644 index 0000000000..3e4629907a --- /dev/null +++ b/tests/Avalonia.Headless.XUnit.UnitTests/AssemblyInfo.cs @@ -0,0 +1,7 @@ +global using Xunit; +global using Avalonia.Headless.XUnit; +using Avalonia.Headless; +using Avalonia.Headless.UnitTests; +using Avalonia.Headless.XUnit; + +[assembly: AvaloniaTestApplication(typeof(TestApplication))] diff --git a/tests/Avalonia.Headless.UnitTests/Avalonia.Headless.UnitTests.csproj b/tests/Avalonia.Headless.XUnit.UnitTests/Avalonia.Headless.XUnit.UnitTests.csproj similarity index 83% rename from tests/Avalonia.Headless.UnitTests/Avalonia.Headless.UnitTests.csproj rename to tests/Avalonia.Headless.XUnit.UnitTests/Avalonia.Headless.XUnit.UnitTests.csproj index 78a3ab186e..25578c3ad8 100644 --- a/tests/Avalonia.Headless.UnitTests/Avalonia.Headless.UnitTests.csproj +++ b/tests/Avalonia.Headless.XUnit.UnitTests/Avalonia.Headless.XUnit.UnitTests.csproj @@ -1,9 +1,10 @@ - net6.0 true + $(DefineConstants);XUNIT + @@ -11,6 +12,10 @@ + + + + From ae04033c76f0cfab4e2183538104887d10ca1410 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 00:48:00 -0400 Subject: [PATCH 05/40] Use ExecutionQueue in test frameworks --- .../AvaloniaTestCommand.cs | 47 ++++++++-------- .../Avalonia.Headless.NUnit/ExecutionQueue.cs | 54 +++++++++++++++---- .../Avalonia.Headless.XUnit.csproj | 6 +++ .../AvaloniaTestCase.cs | 10 ++-- .../AvaloniaTheoryTestCase.cs | 9 ++-- .../Avalonia.Headless.UnitTests/InputTests.cs | 2 +- 6 files changed, 86 insertions(+), 42 deletions(-) diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs index e5eabb612a..2e761616f6 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs @@ -17,28 +17,31 @@ internal class AvaloniaTestCommand : DelegatingTestCommand public override TestResult Execute(TestExecutionContext context) { - return _session.Dispatcher.InvokeAsync>(async () => + return _session.Dispatcher.InvokeOnQueue(() => ExecuteTestMethod(context)); + } + + // Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work. + private async Task ExecuteTestMethod(TestExecutionContext context) + { + var testMethod = innerCommand.Test.Method; + var methodInfo = testMethod!.MethodInfo; + + var result = methodInfo.Invoke(context.TestObject, innerCommand.Test.Arguments); + // Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks. + if (result is Task task) { - var testMethod = innerCommand.Test.Method; - var methodInfo = testMethod!.MethodInfo; - - var result = methodInfo.Invoke(context.TestObject, innerCommand.Test.Arguments); - // Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks. - if (result is Task task) - { - await task; - } - else if (result is ValueTask valueTask) - { - await valueTask; - } - - context.CurrentResult.SetResult(ResultState.Success); - - if (context.CurrentResult.AssertionResults.Count > 0) - context.CurrentResult.RecordTestCompletion(); - - return context.CurrentResult; - }).GetTask().Unwrap().Result; + await task; + } + else if (result is ValueTask valueTask) + { + await valueTask; + } + + context.CurrentResult.SetResult(ResultState.Success); + + if (context.CurrentResult.AssertionResults.Count > 0) + context.CurrentResult.RecordTestCompletion(); + + return context.CurrentResult; } } diff --git a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs index 5488fc956a..ebaca54164 100644 --- a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs +++ b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs @@ -1,35 +1,71 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; namespace Avalonia.Headless.NUnit; +// To force all tests (which might run in parallel) to be executed in a queue. internal static class ExecutionQueue { - static bool _running; - static Queue> _queue = new(); - static async void TryExecuteNext() + private static bool s_running; + private static Queue> s_queue = new(); + + private static async void TryExecuteNext() { - if (_running || _queue.Count == 0) return; + if (s_running || s_queue.Count == 0) return; try { - _running = true; - await _queue.Dequeue()(); + s_running = true; + await s_queue.Dequeue()(); } finally { - _running = false; + s_running = false; } TryExecuteNext(); } - static void ExecuteOnQueue(this Dispatcher dispatcher, Func cb) + private static void PostToTheQueue(this Dispatcher dispatcher, Func cb) { dispatcher.Post(() => { - _queue.Enqueue(cb); + s_queue.Enqueue(cb); TryExecuteNext(); }); } + + internal static Task ExecuteOnQueue(this Dispatcher dispatcher, Func> cb) + { + var tcs = new TaskCompletionSource(); + PostToTheQueue(dispatcher, async () => + { + try + { + var result = await cb(); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + return tcs.Task; + } + + public static TResult InvokeOnQueue(this Dispatcher dispatcher, Func> cb, CancellationToken cancellationToken = default) + { + return dispatcher + .InvokeAsync(() => ExecuteOnQueue(dispatcher, cb), DispatcherPriority.Normal, cancellationToken) + .GetTask().Unwrap() + .Result; + } + + public static Task InvokeOnQueueAsync(this Dispatcher dispatcher, Func> cb, CancellationToken cancellationToken = default) + { + return dispatcher + .InvokeAsync(() => ExecuteOnQueue(dispatcher, cb), DispatcherPriority.Normal, cancellationToken) + .GetTask().Unwrap(); + } } diff --git a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj index df5822dc01..c01dc5a350 100644 --- a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj +++ b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj @@ -12,6 +12,12 @@ + + + ExecutionQueue.cs + + + diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs index fd124aec75..b6c0cd11c4 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using Avalonia.Headless.NUnit; using Avalonia.Threading; using Xunit.Abstractions; using Xunit.Sdk; @@ -34,16 +35,15 @@ internal class AvaloniaTestCase : XunitTestCase { var session = HeadlessUnitTestSession.GetOrStartForAssembly(Method.ToRuntimeMethod().DeclaringType?.Assembly); - var task = session.Dispatcher.InvokeAsync>(async () => + // We need to block the XUnit thread to ensure its concurrency throttle is effective. + // See https://github.com/AArnott/Xunit.StaFact/pull/55#issuecomment-826187354 for details. + var runSummary = session.Dispatcher.InvokeOnQueue(async () => { var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, TestMethodArguments, messageBus, aggregator, cancellationTokenSource); return await runner.RunAsync(); - }, default, cancellationTokenSource.Token).GetTask().Unwrap(); + }, cancellationTokenSource.Token); - // We need to block the XUnit thread to ensure its concurrency throttle is effective. - // See https://github.com/AArnott/Xunit.StaFact/pull/55#issuecomment-826187354 for details. - var runSummary = task.GetAwaiter().GetResult(); return Task.FromResult(runSummary); } } diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs index 454fe48e07..4b56de6e6c 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using Avalonia.Headless.NUnit; using Xunit.Abstractions; using Xunit.Sdk; @@ -20,17 +21,15 @@ internal class AvaloniaTheoryTestCase : XunitTheoryTestCase { } - public override async Task RunAsync(IMessageSink diagnosticMessageSink, IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) + public override Task RunAsync(IMessageSink diagnosticMessageSink, IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) { var session = HeadlessUnitTestSession.GetOrStartForAssembly(Method.ToRuntimeMethod().DeclaringType?.Assembly); - var task = session.Dispatcher.InvokeAsync>(async () => + return session.Dispatcher.InvokeOnQueueAsync(async () => { var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, TestMethodArguments, messageBus, aggregator, cancellationTokenSource); return await runner.RunAsync(); - }, default, cancellationTokenSource.Token).GetTask().Unwrap(); - - return await task; + }, cancellationTokenSource.Token); } } diff --git a/tests/Avalonia.Headless.UnitTests/InputTests.cs b/tests/Avalonia.Headless.UnitTests/InputTests.cs index 3fcd509fe2..ebc3cd3e95 100644 --- a/tests/Avalonia.Headless.UnitTests/InputTests.cs +++ b/tests/Avalonia.Headless.UnitTests/InputTests.cs @@ -29,7 +29,7 @@ public class InputTests window.MouseDown(new Point(50, 50), MouseButton.Left); window.MouseUp(new Point(50, 50), MouseButton.Left); - + Assert.True(buttonClicked); } } From 47f610abf32c29c90679b6c4eeb79f973641d4b8 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 00:59:15 -0400 Subject: [PATCH 06/40] Fix inconsistent async/non-async methods --- src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index 3339d9a48c..382cbbdee5 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -55,7 +55,7 @@ public sealed class HeadlessUnitTestSession : IDisposable /// Parameter from which should be created. /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. /// - public static Task StartNew< + public static HeadlessUnitTestSession StartNew< [DynamicallyAccessedMembers(DynamicallyAccessed)] TEntryPointType>() { return StartNew(typeof(TEntryPointType)); @@ -68,7 +68,7 @@ public sealed class HeadlessUnitTestSession : IDisposable /// Parameter from which should be created. /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. /// - public static Task StartNew( + public static HeadlessUnitTestSession StartNew( [DynamicallyAccessedMembers(DynamicallyAccessed)] Type entryPointType) { var tcs = new TaskCompletionSource(); @@ -96,13 +96,14 @@ public sealed class HeadlessUnitTestSession : IDisposable catch (Exception e) { tcs.SetException(e); + return; } Dispatcher.UIThread.MainLoop(cancellationTokenSource.Token); }) { IsBackground = true }; thread.Start(); - return tcs.Task; + return tcs.Task.GetAwaiter().GetResult(); } /// From d8da2179f2f129117d292ce72257cbc10e674ee6 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 01:03:53 -0400 Subject: [PATCH 07/40] Fix build --- src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index 382cbbdee5..a3059c6d3e 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -134,7 +134,7 @@ public sealed class HeadlessUnitTestSession : IDisposable } - s_session = appBuilderEntryPointType is not null ? StartNew(appBuilderEntryPointType).Result : StartNew(typeof(Application)).Result; + s_session = appBuilderEntryPointType is not null ? StartNew(appBuilderEntryPointType) : StartNew(typeof(Application)); return s_session; } From eabd3ca7f0d6a5686793436f8e264b532dfa8ff9 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 02:32:28 -0400 Subject: [PATCH 08/40] Fix pack and execute tests on CI --- nukebuild/Build.cs | 3 ++- .../Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj | 1 + .../Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/nukebuild/Build.cs b/nukebuild/Build.cs index e17bad28d7..56f61134f0 100644 --- a/nukebuild/Build.cs +++ b/nukebuild/Build.cs @@ -212,7 +212,8 @@ partial class Build : NukeBuild RunCoreTest("Avalonia.Markup.Xaml.UnitTests"); RunCoreTest("Avalonia.Skia.UnitTests"); RunCoreTest("Avalonia.ReactiveUI.UnitTests"); - RunCoreTest("Avalonia.Headless.UnitTests"); + RunCoreTest("Avalonia.Headless.NUnit.UnitTests"); + RunCoreTest("Avalonia.Headless.XUnit.UnitTests"); }); Target RunRenderTests => _ => _ diff --git a/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj b/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj index 3b8950d5d1..49f1de31f2 100644 --- a/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj +++ b/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj @@ -1,6 +1,7 @@ netstandard2.0;net6.0 + false diff --git a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj index c01dc5a350..c244a1a4cd 100644 --- a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj +++ b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj @@ -1,6 +1,7 @@ netstandard2.0;net6.0 + false From 321bf26a0a7d732d9525cf19abf0c1d3841a4a8e Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 04:33:39 -0400 Subject: [PATCH 09/40] Support SetUp and TearDown methods by NUnit --- .../AvaloniaTestCommand.cs | 57 +++++++++++++------ .../Avalonia.Headless.UnitTests/InputTests.cs | 48 ++++++++++++---- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs index 2e761616f6..1b00f3c9fe 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System; +using System.Reflection; +using System.Threading.Tasks; using NUnit.Framework.Interfaces; using NUnit.Framework.Internal; using NUnit.Framework.Internal.Commands; @@ -9,6 +11,11 @@ internal class AvaloniaTestCommand : DelegatingTestCommand { private readonly HeadlessUnitTestSession _session; + private static FieldInfo s_beforeTest = typeof(BeforeAndAfterTestCommand) + .GetField("BeforeTest", BindingFlags.Instance | BindingFlags.NonPublic)!; + private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand) + .GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!; + public AvaloniaTestCommand(HeadlessUnitTestSession session, TestCommand innerCommand) : base(innerCommand) { @@ -23,25 +30,41 @@ internal class AvaloniaTestCommand : DelegatingTestCommand // Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work. private async Task ExecuteTestMethod(TestExecutionContext context) { - var testMethod = innerCommand.Test.Method; - var methodInfo = testMethod!.MethodInfo; - - var result = methodInfo.Invoke(context.TestObject, innerCommand.Test.Arguments); - // Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks. - if (result is Task task) - { - await task; - } - else if (result is ValueTask valueTask) + try { - await valueTask; - } + if (innerCommand is BeforeAndAfterTestCommand beforeTestCommand) + { + (s_beforeTest.GetValue(beforeTestCommand) as Action)?.Invoke(context); + } + + var testMethod = innerCommand.Test.Method; + var methodInfo = testMethod!.MethodInfo; - context.CurrentResult.SetResult(ResultState.Success); + var result = methodInfo.Invoke(context.TestObject, innerCommand.Test.Arguments); + // Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks. + if (result is Task task) + { + await task; + } + else if (result is ValueTask valueTask) + { + await valueTask; + } - if (context.CurrentResult.AssertionResults.Count > 0) - context.CurrentResult.RecordTestCompletion(); + context.CurrentResult.SetResult(ResultState.Success); - return context.CurrentResult; + if (context.CurrentResult.AssertionResults.Count > 0) + context.CurrentResult.RecordTestCompletion(); + + return context.CurrentResult; + } + finally + { + if (innerCommand is BeforeAndAfterTestCommand beforeTestCommand + && context.ExecutionStatus != TestExecutionStatus.AbortRequested) + { + (s_afterTest.GetValue(beforeTestCommand) as Action)?.Invoke(context); + } + } } } diff --git a/tests/Avalonia.Headless.UnitTests/InputTests.cs b/tests/Avalonia.Headless.UnitTests/InputTests.cs index ebc3cd3e95..5e3b6e762f 100644 --- a/tests/Avalonia.Headless.UnitTests/InputTests.cs +++ b/tests/Avalonia.Headless.UnitTests/InputTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Reactive.Disposables; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; @@ -6,7 +8,27 @@ using Avalonia.Threading; namespace Avalonia.Headless.UnitTests; public class InputTests +#if XUNIT + : IDisposable +#endif { + private Window _window; + +#if NUNIT + [SetUp] + public void SetUp() +#elif XUNIT + public InputTests() +#endif + { + Dispatcher.UIThread.VerifyAccess(); + _window = new Window + { + Width = 100, + Height = 100 + }; + } + [AvaloniaFact] public void Should_Click_Button_On_Window() { @@ -18,18 +40,24 @@ public class InputTests }; button.Click += (_, _) => buttonClicked = true; + + _window.Content = button; + _window.Show(); - var window = new Window - { - Width = 100, - Height = 100, - Content = button - }; - window.Show(); - - window.MouseDown(new Point(50, 50), MouseButton.Left); - window.MouseUp(new Point(50, 50), MouseButton.Left); + _window.MouseDown(new Point(50, 50), MouseButton.Left); + _window.MouseUp(new Point(50, 50), MouseButton.Left); Assert.True(buttonClicked); } + +#if NUNIT + [TearDown] + public void TearDown() +#elif XUNIT + public void Dispose() +#endif + { + Dispatcher.UIThread.VerifyAccess(); + _window.Close(); + } } From e2805e41181cd41d4241a49cbd9513939322345f Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 04:36:45 -0400 Subject: [PATCH 10/40] Use GetAwaiter().GetResult() --- src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs index ebaca54164..b42f126389 100644 --- a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs +++ b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs @@ -59,7 +59,7 @@ internal static class ExecutionQueue return dispatcher .InvokeAsync(() => ExecuteOnQueue(dispatcher, cb), DispatcherPriority.Normal, cancellationToken) .GetTask().Unwrap() - .Result; + .GetAwaiter().GetResult(); } public static Task InvokeOnQueueAsync(this Dispatcher dispatcher, Func> cb, CancellationToken cancellationToken = default) From e61e540c23b837553d88f3df46f349696bb085ca Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 05:00:34 -0400 Subject: [PATCH 11/40] Add missing ITextShaperImpl --- .../Avalonia.Headless/HeadlessPlatformRenderInterface.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index b23697fd2a..b25e18b2d7 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -20,7 +20,8 @@ namespace Avalonia.Headless { AvaloniaLocator.CurrentMutable .Bind().ToConstant(new HeadlessPlatformRenderInterface()) - .Bind().ToConstant(new HeadlessFontManagerStub()); + .Bind().ToConstant(new HeadlessFontManagerStub()) + .Bind().ToConstant(new HeadlessTextShaperStub()); } public IEnumerable InstalledFontNames { get; } = new[] { "Tahoma" }; From 629900d6d872a94481a82c36e3a87ce847cbb1f6 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 23:07:04 -0400 Subject: [PATCH 12/40] Instead of creating a fake test command, try to iterate over existing commands structure and replace only what's needed --- .../Avalonia.Headless.NUnit/AvaloniaTest.cs | 2 +- .../AvaloniaTestCommand.cs | 70 --------------- .../AvaloniaTestMethodCommand.cs | 85 +++++++++++++++++++ .../Avalonia.Headless.NUnit/AvaloniaTheory.cs | 2 +- .../Avalonia.Headless.UnitTests/InputTests.cs | 2 +- 5 files changed, 88 insertions(+), 73 deletions(-) delete mode 100644 src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs create mode 100644 src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs index fd04146391..94b75cf849 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs @@ -20,6 +20,6 @@ public sealed class AvaloniaTestAttribute : TestCaseAttribute, IWrapSetUpTearDow var session = HeadlessUnitTestSession.GetOrStartForAssembly(command.Test.Method?.MethodInfo.DeclaringType?.Assembly); - return new AvaloniaTestCommand(session, command); + return AvaloniaTestMethodCommand.ProcessCommand(session, command); } } diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs deleted file mode 100644 index 1b00f3c9fe..0000000000 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Reflection; -using System.Threading.Tasks; -using NUnit.Framework.Interfaces; -using NUnit.Framework.Internal; -using NUnit.Framework.Internal.Commands; - -namespace Avalonia.Headless.NUnit; - -internal class AvaloniaTestCommand : DelegatingTestCommand -{ - private readonly HeadlessUnitTestSession _session; - - private static FieldInfo s_beforeTest = typeof(BeforeAndAfterTestCommand) - .GetField("BeforeTest", BindingFlags.Instance | BindingFlags.NonPublic)!; - private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand) - .GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!; - - public AvaloniaTestCommand(HeadlessUnitTestSession session, TestCommand innerCommand) - : base(innerCommand) - { - _session = session; - } - - public override TestResult Execute(TestExecutionContext context) - { - return _session.Dispatcher.InvokeOnQueue(() => ExecuteTestMethod(context)); - } - - // Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work. - private async Task ExecuteTestMethod(TestExecutionContext context) - { - try - { - if (innerCommand is BeforeAndAfterTestCommand beforeTestCommand) - { - (s_beforeTest.GetValue(beforeTestCommand) as Action)?.Invoke(context); - } - - var testMethod = innerCommand.Test.Method; - var methodInfo = testMethod!.MethodInfo; - - var result = methodInfo.Invoke(context.TestObject, innerCommand.Test.Arguments); - // Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks. - if (result is Task task) - { - await task; - } - else if (result is ValueTask valueTask) - { - await valueTask; - } - - context.CurrentResult.SetResult(ResultState.Success); - - if (context.CurrentResult.AssertionResults.Count > 0) - context.CurrentResult.RecordTestCompletion(); - - return context.CurrentResult; - } - finally - { - if (innerCommand is BeforeAndAfterTestCommand beforeTestCommand - && context.ExecutionStatus != TestExecutionStatus.AbortRequested) - { - (s_afterTest.GetValue(beforeTestCommand) as Action)?.Invoke(context); - } - } - } -} diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs new file mode 100644 index 0000000000..a7e461f76e --- /dev/null +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs @@ -0,0 +1,85 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal; +using NUnit.Framework.Internal.Commands; + +namespace Avalonia.Headless.NUnit; + +internal class AvaloniaTestMethodCommand : DelegatingTestCommand +{ + private readonly HeadlessUnitTestSession _session; + + private static FieldInfo s_innerCommand = typeof(DelegatingTestCommand) + .GetField("innerCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; + private static FieldInfo s_beforeTest = typeof(BeforeAndAfterTestCommand) + .GetField("BeforeTest", BindingFlags.Instance | BindingFlags.NonPublic)!; + private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand) + .GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private AvaloniaTestMethodCommand(HeadlessUnitTestSession session, TestCommand innerCommand) + : base(innerCommand) + { + _session = session; + } + + public static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command) + { + if (command is BeforeAndAfterTestCommand beforeAndAfterTestCommand) + { + if (s_beforeTest.GetValue(beforeAndAfterTestCommand) is Action beforeTest) + { + Action beforeAction = c => session.Dispatcher.Invoke(() => beforeTest(c)); + s_beforeTest.SetValue(beforeAndAfterTestCommand, beforeAction); + } + if (s_afterTest.GetValue(beforeAndAfterTestCommand) is Action afterTest) + { + Action afterAction = c => session.Dispatcher.Invoke(() => afterTest(c)); + s_afterTest.SetValue(beforeAndAfterTestCommand, afterAction); + } + } + + if (command is DelegatingTestCommand delegatingTestCommand + && s_innerCommand.GetValue(delegatingTestCommand) is TestCommand inner) + { + s_innerCommand.SetValue(delegatingTestCommand, ProcessCommand(session, inner)); + } + else if (command is TestMethodCommand methodCommand) + { + return new AvaloniaTestMethodCommand(session, methodCommand); + } + + return command; + } + + public override TestResult Execute(TestExecutionContext context) + { + return _session.Dispatcher.InvokeOnQueue(() => ExecuteTestMethod(context)); + } + + // Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work. + private async Task ExecuteTestMethod(TestExecutionContext context) + { + var testMethod = innerCommand.Test.Method; + var methodInfo = testMethod!.MethodInfo; + + var result = methodInfo.Invoke(context.TestObject, innerCommand.Test.Arguments); + // Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks. + if (result is Task task) + { + await task; + } + else if (result is ValueTask valueTask) + { + await valueTask; + } + + context.CurrentResult.SetResult(ResultState.Success); + + if (context.CurrentResult.AssertionResults.Count > 0) + context.CurrentResult.RecordTestCompletion(); + + return context.CurrentResult; + } +} diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs index 1f3cadd296..85ed67dbd2 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs @@ -19,6 +19,6 @@ public sealed class AvaloniaTheoryAttribute : TheoryAttribute, IWrapSetUpTearDow { var session = HeadlessUnitTestSession.GetOrStartForAssembly(command.Test.Method?.MethodInfo.DeclaringType?.Assembly); - return new AvaloniaTestCommand(session, command); + return AvaloniaTestMethodCommand.ProcessCommand(session, command); } } diff --git a/tests/Avalonia.Headless.UnitTests/InputTests.cs b/tests/Avalonia.Headless.UnitTests/InputTests.cs index 5e3b6e762f..5a7d3faae9 100644 --- a/tests/Avalonia.Headless.UnitTests/InputTests.cs +++ b/tests/Avalonia.Headless.UnitTests/InputTests.cs @@ -46,7 +46,7 @@ public class InputTests _window.MouseDown(new Point(50, 50), MouseButton.Left); _window.MouseUp(new Point(50, 50), MouseButton.Left); - + Assert.True(buttonClicked); } From 8b6311710ea795f648a5300d7b65cf9e1fcd9ebe Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 23:15:51 -0400 Subject: [PATCH 13/40] Check for SupportsRunLoops --- src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index a3059c6d3e..12f8515209 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -89,6 +89,11 @@ public sealed class HeadlessUnitTestSession : IDisposable appBuilder.SetupWithoutStarting(); + if (!Dispatcher.UIThread.SupportsRunLoops) + { + throw new InvalidOperationException("Avalonia Headless platform has failed to initialize."); + } + // ReSharper disable once AccessToModifiedClosure tcs.SetResult(new HeadlessUnitTestSession(entryPointType, Application.Current!, SynchronizationContext.Current!, Dispatcher.UIThread, cancellationTokenSource)); From 4aa6035ec327a535ca9af671b1f01d798209f795 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 21:05:32 -0400 Subject: [PATCH 14/40] Reuse text and geometry related headless mocks in Avalonia unit tests and benchmarks --- .../Avalonia.Headless.csproj | 9 + .../HeadlessPlatformRenderInterface.cs | 95 +++++- .../HeadlessPlatformStubs.cs | 109 +++++-- .../Media/FontManagerTests.cs | 7 +- .../Media/GlyphRunTests.cs | 5 +- .../Rendering/CompositorHitTestingTests.cs | 1 + .../VisualTree/MockRenderInterface.cs | 285 ------------------ .../Layout/ControlsBenchmark.cs | 7 +- .../Avalonia.Benchmarks/NullCursorFactory.cs | 17 -- .../NullDrawingContextImpl.cs | 106 ------- tests/Avalonia.Benchmarks/NullGlyphRun.cs | 21 -- .../NullRenderingPlatform.cs | 149 --------- .../NullThreadingPlatform.cs | 27 -- .../Rendering/ShapeRendering.cs | 5 +- .../Styling/ControlTheme_Change.cs | 5 +- .../Styling/ResourceBenchmarks.cs | 5 +- .../Styling/Style_Apply_Detach_Complex.cs | 5 +- .../Text/HugeTextLayout.cs | 5 +- .../Themes/FluentBenchmark.cs | 3 - .../DatePickerTests.cs | 7 +- .../ItemsControlTests.cs | 7 +- .../MaskedTextBoxTests.cs | 13 +- .../SelectingItemsControlTests_Multiple.cs | 7 +- .../TextBoxTests.cs | 11 +- .../TextBoxTests_DataValidation.cs | 5 +- .../TimePickerTests.cs | 7 +- .../TreeViewTests.cs | 7 +- .../Xaml/StyleTests.cs | 2 + .../Media/FontManagerTests.cs | 3 +- .../Media/TextFormatting/TextLayoutTests.cs | 1 + .../Media/TextFormatting/TextLineTests.cs | 1 + .../Avalonia.UnitTests.csproj | 1 + .../Avalonia.UnitTests/ImmediateDispatcher.cs | 62 ---- .../Avalonia.UnitTests/MockFontManagerImpl.cs | 63 ---- tests/Avalonia.UnitTests/MockGlyphRun.cs | 33 -- tests/Avalonia.UnitTests/MockGlyphTypeface.cs | 81 ----- .../MockPlatformRenderInterface.cs | 191 ------------ .../MockStreamGeometryImpl.cs | 179 ----------- .../Avalonia.UnitTests/MockTextShaperImpl.cs | 38 --- tests/Avalonia.UnitTests/TestServices.cs | 34 +-- tests/Avalonia.UnitTests/TextTestHelper.cs | 15 - 41 files changed, 248 insertions(+), 1386 deletions(-) delete mode 100644 tests/Avalonia.Base.UnitTests/VisualTree/MockRenderInterface.cs delete mode 100644 tests/Avalonia.Benchmarks/NullCursorFactory.cs delete mode 100644 tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs delete mode 100644 tests/Avalonia.Benchmarks/NullGlyphRun.cs delete mode 100644 tests/Avalonia.Benchmarks/NullRenderingPlatform.cs delete mode 100644 tests/Avalonia.Benchmarks/NullThreadingPlatform.cs delete mode 100644 tests/Avalonia.UnitTests/ImmediateDispatcher.cs delete mode 100644 tests/Avalonia.UnitTests/MockFontManagerImpl.cs delete mode 100644 tests/Avalonia.UnitTests/MockGlyphRun.cs delete mode 100644 tests/Avalonia.UnitTests/MockGlyphTypeface.cs delete mode 100644 tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs delete mode 100644 tests/Avalonia.UnitTests/MockStreamGeometryImpl.cs delete mode 100644 tests/Avalonia.UnitTests/MockTextShaperImpl.cs delete mode 100644 tests/Avalonia.UnitTests/TextTestHelper.cs diff --git a/src/Headless/Avalonia.Headless/Avalonia.Headless.csproj b/src/Headless/Avalonia.Headless/Avalonia.Headless.csproj index b626eaeb68..893cb0074c 100644 --- a/src/Headless/Avalonia.Headless/Avalonia.Headless.csproj +++ b/src/Headless/Avalonia.Headless/Avalonia.Headless.csproj @@ -12,7 +12,16 @@ + + + + + + + + + diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index b25e18b2d7..a98fe0e176 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -130,12 +130,29 @@ namespace Avalonia.Headless IReadOnlyList glyphInfos, Point baselineOrigin) { - return new HeadlessGlyphRunStub(); + return new HeadlessGlyphRunStub(glyphInfos); } - private class HeadlessGlyphRunStub : IGlyphRunImpl + internal class HeadlessGlyphRunStub : IGlyphRunImpl { - public Rect Bounds => new Rect(new Size(8, 12)); + public HeadlessGlyphRunStub(IReadOnlyList glyphInfos) + { + var width = 0.0; + + for (var i = 0; i < glyphInfos.Count; ++i) + { + width += glyphInfos[i].GlyphAdvance; + } + + Bounds = new Rect(new Size(width, 10)); + } + + public HeadlessGlyphRunStub() + { + Bounds = new Rect(new Size(8, 10)); + } + + public Rect Bounds { get; } public Point BaselineOrigin => new Point(0, 8); @@ -232,8 +249,11 @@ namespace Avalonia.Headless private class HeadlessStreamingGeometryStub : HeadlessGeometryStub, IStreamGeometryImpl { + private HeadlessStreamingGeometryContextStub _context; + public HeadlessStreamingGeometryStub() : base(default) { + _context = new HeadlessStreamingGeometryContextStub(this); } public IStreamGeometryImpl Clone() @@ -243,13 +263,18 @@ namespace Avalonia.Headless public IStreamGeometryContextImpl Open() { - return new HeadlessStreamingGeometryContextStub(this); + return _context; + } + + public override bool FillContains(Point point) + { + return _context.FillContains(point); } private class HeadlessStreamingGeometryContextStub : IStreamGeometryContextImpl { private readonly HeadlessStreamingGeometryStub _parent; - private double _x1, _y1, _x2, _y2; + private List points = new List(); public HeadlessStreamingGeometryContextStub(HeadlessStreamingGeometryStub parent) { _parent = parent; @@ -257,19 +282,30 @@ namespace Avalonia.Headless private void Track(Point pt) { - if (_x1 > pt.X) - _x1 = pt.X; - if (_x2 < pt.X) - _x2 = pt.X; - if (_y1 > pt.Y) - _y1 = pt.Y; - if (_y2 < pt.Y) - _y2 = pt.Y; + points.Add(pt); } + public Rect CalculateBounds() + { + var left = double.MaxValue; + var right = double.MinValue; + var top = double.MaxValue; + var bottom = double.MinValue; + + foreach (var p in points) + { + left = Math.Min(p.X, left); + right = Math.Max(p.X, right); + top = Math.Min(p.Y, top); + bottom = Math.Max(p.Y, bottom); + } + + return new Rect(new Point(left, top), new Point(right, bottom)); + } + public void Dispose() { - _parent.Bounds = new Rect(_x1, _y1, _x2 - _x1, _y2 - _y1); + _parent.Bounds = CalculateBounds(); } public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, SweepDirection sweepDirection) @@ -301,6 +337,35 @@ namespace Avalonia.Headless { } + + public bool FillContains(Point point) + { + // Use the algorithm from https://www.blackpawn.com/texts/pointinpoly/default.html + // to determine if the point is in the geometry (since it will always be convex in this situation) + for (int i = 0; i < points.Count; i++) + { + var a = points[i]; + var b = points[(i + 1) % points.Count]; + var c = points[(i + 2) % points.Count]; + + Vector v0 = c - a; + Vector v1 = b - a; + Vector v2 = point - a; + + var dot00 = v0 * v0; + var dot01 = v0 * v1; + var dot02 = v0 * v2; + var dot11 = v1 * v1; + var dot12 = v1 * v2; + + + var invDenom = 1 / (dot00 * dot11 - dot01 * dot01); + var u = (dot11 * dot02 - dot01 * dot12) * invDenom; + var v = (dot00 * dot12 - dot01 * dot02) * invDenom; + if ((u >= 0) && (v >= 0) && (u + v < 1)) return true; + } + return false; + } } } @@ -366,7 +431,7 @@ namespace Avalonia.Headless } } - private class HeadlessDrawingContextStub : IDrawingContextImpl + internal class HeadlessDrawingContextStub : IDrawingContextImpl { public void Dispose() { diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs index 769fea7c6e..471ea9a6d0 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.Platform; @@ -11,6 +13,7 @@ using Avalonia.Input.Platform; using Avalonia.Media; using Avalonia.Media.Fonts; using Avalonia.Media.TextFormatting; +using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Platform.Storage.FileIO; @@ -82,22 +85,22 @@ namespace Avalonia.Headless { public FontMetrics Metrics => new FontMetrics { - DesignEmHeight = 1, - Ascent = 8, - Descent = 4, + DesignEmHeight = 10, + Ascent = 2, + Descent = 10, + IsFixedPitch = true, LineGap = 0, UnderlinePosition = 2, UnderlineThickness = 1, StrikethroughPosition = 2, - StrikethroughThickness = 1, - IsFixedPitch = true + StrikethroughThickness = 1 }; public int GlyphCount => 1337; - public FontSimulations FontSimulations { get; } + public FontSimulations FontSimulations => FontSimulations.None; - public string FamilyName => "Arial"; + public string FamilyName => "$Default"; public FontWeight Weight => FontWeight.Normal; @@ -111,24 +114,31 @@ namespace Avalonia.Headless public ushort GetGlyph(uint codepoint) { - return 1; + return (ushort)codepoint; } public bool TryGetGlyph(uint codepoint, out ushort glyph) { - glyph = 1; + glyph = 8; return true; } public int GetGlyphAdvance(ushort glyph) { - return 12; + return 8; } public int[] GetGlyphAdvances(ReadOnlySpan glyphs) { - return glyphs.ToArray().Select(x => (int)x).ToArray(); + var advances = new int[glyphs.Length]; + + for (var i = 0; i < advances.Length; i++) + { + advances[i] = 8; + } + + return advances; } public ushort[] GetGlyphs(ReadOnlySpan codepoints) @@ -146,8 +156,8 @@ namespace Avalonia.Headless { metrics = new GlyphMetrics { - Height = 10, - Width = 8 + Width = 10, + Height = 10 }; return true; @@ -161,40 +171,81 @@ namespace Avalonia.Headless var typeface = options.Typeface; var fontRenderingEmSize = options.FontRenderingEmSize; var bidiLevel = options.BidiLevel; + var shapedBuffer = new ShapedBuffer(text, text.Length, typeface, fontRenderingEmSize, bidiLevel); + var textSpan = text.Span; + var textStartIndex = TextTestHelper.GetStartCharIndex(text); + + for (var i = 0; i < shapedBuffer.Length;) + { + var glyphCluster = i + textStartIndex; + + var codepoint = Codepoint.ReadAt(textSpan, i, out var count); + + var glyphIndex = typeface.GetGlyph(codepoint); - return new ShapedBuffer(text, text.Length, typeface, fontRenderingEmSize, bidiLevel); + for (var j = 0; j < count; ++j) + { + shapedBuffer[i + j] = new GlyphInfo(glyphIndex, glyphCluster, 10); + } + + i += count; + } + + return shapedBuffer; } } internal class HeadlessFontManagerStub : IFontManagerImpl { + private readonly string _defaultFamilyName; + + public HeadlessFontManagerStub(string defaultFamilyName = "Default") + { + _defaultFamilyName = defaultFamilyName; + } + + public int TryCreateGlyphTypefaceCount { get; private set; } + public string GetDefaultFontFamilyName() { - return "Arial"; + return _defaultFamilyName; } - public string[] GetInstalledFontFamilyNames(bool checkForUpdates = false) + string[] IFontManagerImpl.GetInstalledFontFamilyNames(bool checkForUpdates) { - return new string[] { "Arial" }; + return new[] { _defaultFamilyName }; } - public bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, out IGlyphTypeface glyphTypeface) + public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, + FontStretch fontStretch, + CultureInfo? culture, out Typeface fontKey) { - glyphTypeface= new HeadlessGlyphTypefaceImpl(); + fontKey = new Typeface(_defaultFamilyName); - return true; + return false; } - public bool TryCreateGlyphTypeface(Stream stream, out IGlyphTypeface glyphTypeface) + public virtual bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight, + FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) { - glyphTypeface = new HeadlessGlyphTypefaceImpl(); + glyphTypeface = null; + + TryCreateGlyphTypefaceCount++; + + if (familyName == "Unknown") + { + return false; + } + + glyphTypeface = new HeadlessGlyphTypefaceImpl(); return true; } - public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo? culture, out Typeface typeface) + public virtual bool TryCreateGlyphTypeface(Stream stream, out IGlyphTypeface glyphTypeface) { - typeface = new Typeface("Arial", fontStyle, fontWeight, fontStretch); + glyphTypeface = new HeadlessGlyphTypefaceImpl(); + return true; } } @@ -249,4 +300,14 @@ namespace Avalonia.Headless return ScreenHelper.ScreenFromWindow(window, AllScreens); } } + + internal static class TextTestHelper + { + public static int GetStartCharIndex(ReadOnlyMemory text) + { + if (!MemoryMarshal.TryGetString(text, out _, out var start, out _)) + throw new InvalidOperationException("text memory should have been a string"); + return start; + } + } } diff --git a/tests/Avalonia.Base.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Base.UnitTests/Media/FontManagerTests.cs index 3ccec872d2..adb5431ce6 100644 --- a/tests/Avalonia.Base.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/FontManagerTests.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Headless; using Avalonia.Media; using Avalonia.UnitTests; using Xunit; @@ -27,7 +28,7 @@ namespace Avalonia.Base.UnitTests.Media [Fact] public void Should_Throw_When_Default_FamilyName_Is_Null() { - using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new MockFontManagerImpl(null)))) + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new HeadlessFontManagerStub(null!)))) { Assert.Throws(() => FontManager.Current); } @@ -39,7 +40,7 @@ namespace Avalonia.Base.UnitTests.Media var options = new FontManagerOptions { DefaultFamilyName = "MyFont" }; using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface - .With(fontManagerImpl: new MockFontManagerImpl()))) + .With(fontManagerImpl: new HeadlessFontManagerStub()))) { AvaloniaLocator.CurrentMutable.Bind().ToConstant(options); @@ -62,7 +63,7 @@ namespace Avalonia.Base.UnitTests.Media }; using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface - .With(fontManagerImpl: new MockFontManagerImpl()))) + .With(fontManagerImpl: new HeadlessFontManagerStub()))) { AvaloniaLocator.CurrentMutable.Bind().ToConstant(options); diff --git a/tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs index 84ce341e98..c273cc6489 100644 --- a/tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Headless; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.UnitTests; @@ -179,13 +180,13 @@ namespace Avalonia.Base.UnitTests.Media glyphInfos[i] = new GlyphInfo(0, glyphClusters[i], glyphAdvances[i]); } - return new GlyphRun(new MockGlyphTypeface(), 10, new string('a', count).AsMemory(), glyphInfos, biDiLevel: bidiLevel); + return new GlyphRun(new HeadlessGlyphTypefaceImpl(), 10, new string('a', count).AsMemory(), glyphInfos, biDiLevel: bidiLevel); } private static IDisposable Start() { return UnitTestApplication.Start(TestServices.StyledWindow.With( - renderInterface: new MockPlatformRenderInterface())); + renderInterface: new HeadlessPlatformRenderInterface())); } } } diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs index 27bb0355e6..7cd02d2907 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Avalonia.Base.UnitTests.VisualTree; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Controls.Shapes; diff --git a/tests/Avalonia.Base.UnitTests/VisualTree/MockRenderInterface.cs b/tests/Avalonia.Base.UnitTests/VisualTree/MockRenderInterface.cs deleted file mode 100644 index d494c47a55..0000000000 --- a/tests/Avalonia.Base.UnitTests/VisualTree/MockRenderInterface.cs +++ /dev/null @@ -1,285 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.UnitTests; -using Avalonia.Media.Imaging; -using Avalonia.Media.TextFormatting; - -namespace Avalonia.Base.UnitTests.VisualTree -{ - class MockRenderInterface : IPlatformRenderInterface, IPlatformRenderInterfaceContext - { - public IRenderTarget CreateRenderTarget(IEnumerable surfaces) - { - throw new NotImplementedException(); - } - - public bool IsLost => false; - - public object TryGetFeature(Type featureType) => null; - - public IRenderTargetBitmapImpl CreateRenderTargetBitmap(PixelSize size, Vector dpi) - { - throw new NotImplementedException(); - } - - public IStreamGeometryImpl CreateStreamGeometry() - { - return new MockStreamGeometry(); - } - - public IGeometryImpl CreateGeometryGroup(FillRule fillRule, IReadOnlyList children) - { - throw new NotImplementedException(); - } - - public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, IGeometryImpl g1, IGeometryImpl g2) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmap(Stream stream) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmapToWidth(Stream stream, int width, - BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmapToHeight(Stream stream, int height, - BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmap(string fileName) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmap(Stream stream) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmap(string fileName) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmap(PixelFormat format, AlphaFormat alphaFormat, IntPtr data, PixelSize size, Vector dpi, int stride) - { - throw new NotImplementedException(); - } - - public IGlyphRunImpl CreateGlyphRun(IGlyphTypeface glyphTypeface, double fontRenderingEmSize, - IReadOnlyList glyphInfos, Point baselineOrigin) - { - throw new NotImplementedException(); - } - - public IPlatformRenderInterfaceContext CreateBackendContext(IPlatformGraphicsContext graphicsContext) - { - return this; - } - - public bool SupportsIndividualRoundRects { get; set; } - public AlphaFormat DefaultAlphaFormat { get; } - public PixelFormat DefaultPixelFormat { get; } - public bool IsSupportedBitmapPixelFormat(PixelFormat format) => true; - - public IFontManagerImpl CreateFontManager() - { - return new MockFontManagerImpl(); - } - - public IWriteableBitmapImpl CreateWriteableBitmap(PixelSize size, Vector dpi, PixelFormat fmt, AlphaFormat alphaFormat) - { - throw new NotImplementedException(); - } - - public IGeometryImpl CreateEllipseGeometry(Rect rect) - { - throw new NotImplementedException(); - } - - public IGeometryImpl CreateLineGeometry(Point p1, Point p2) - { - throw new NotImplementedException(); - } - - public IGeometryImpl CreateRectangleGeometry(Rect rect) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmapToWidth(Stream stream, int width, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmapToHeight(Stream stream, int height, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IBitmapImpl ResizeBitmap(IBitmapImpl bitmapImpl, PixelSize destinationSize, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IGeometryImpl BuildGlyphRunGeometry(GlyphRun glyphRun) - { - throw new NotImplementedException(); - } - - class MockStreamGeometry : IStreamGeometryImpl - { - private MockStreamGeometryContext _impl = new MockStreamGeometryContext(); - public Rect Bounds - { - get - { - throw new NotImplementedException(); - } - } - - public double ContourLength { get; } - - public IStreamGeometryImpl Clone() - { - return this; - } - - public void Dispose() - { - } - - public bool FillContains(Point point) - { - return _impl.FillContains(point); - } - - public Rect GetRenderBounds(IPen pen) - { - throw new NotImplementedException(); - } - - public IGeometryImpl Intersect(IGeometryImpl geometry) - { - throw new NotImplementedException(); - } - - public IStreamGeometryContextImpl Open() - { - return _impl; - } - - public bool StrokeContains(IPen pen, Point point) - { - throw new NotImplementedException(); - } - - public ITransformedGeometryImpl WithTransform(Matrix transform) - { - throw new NotImplementedException(); - } - - public bool TryGetPointAtDistance(double distance, out Point point) - { - throw new NotImplementedException(); - } - - public bool TryGetPointAndTangentAtDistance(double distance, out Point point, out Point tangent) - { - throw new NotImplementedException(); - } - - public bool TryGetSegment(double startDistance, double stopDistance, bool startOnBeginFigure, out IGeometryImpl segmentGeometry) - { - throw new NotImplementedException(); - } - - class MockStreamGeometryContext : IStreamGeometryContextImpl - { - private List points = new List(); - public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, SweepDirection sweepDirection) - { - throw new NotImplementedException(); - } - - public void BeginFigure(Point startPoint, bool isFilled) - { - points.Add(startPoint); - } - - public void CubicBezierTo(Point point1, Point point2, Point point3) - { - throw new NotImplementedException(); - } - - public void Dispose() - { - } - - public void EndFigure(bool isClosed) - { - } - - public void LineTo(Point point) - { - points.Add(point); - } - - public void QuadraticBezierTo(Point control, Point endPoint) - { - throw new NotImplementedException(); - } - - public void SetFillRule(FillRule fillRule) - { - } - - public bool FillContains(Point point) - { - // Use the algorithm from https://www.blackpawn.com/texts/pointinpoly/default.html - // to determine if the point is in the geometry (since it will always be convex in this situation) - for (int i = 0; i < points.Count; i++) - { - var a = points[i]; - var b = points[(i + 1) % points.Count]; - var c = points[(i + 2) % points.Count]; - - Vector v0 = c - a; - Vector v1 = b - a; - Vector v2 = point - a; - - var dot00 = v0 * v0; - var dot01 = v0 * v1; - var dot02 = v0 * v2; - var dot11 = v1 * v1; - var dot12 = v1 * v2; - - - var invDenom = 1 / (dot00 * dot11 - dot01 * dot01); - var u = (dot11 * dot02 - dot01 * dot12) * invDenom; - var v = (dot00 * dot12 - dot01 * dot02) * invDenom; - if ((u >= 0) && (v >= 0) && (u + v < 1)) return true; - } - return false; - } - } - } - - public void Dispose() - { - - } - } - -} diff --git a/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs b/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs index 5df1dd1679..4202e60fd6 100644 --- a/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs +++ b/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.CompilerServices; using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Threading; using Avalonia.UnitTests; using BenchmarkDotNet.Attributes; @@ -15,11 +16,7 @@ namespace Avalonia.Benchmarks.Layout public ControlsBenchmark() { - _app = UnitTestApplication.Start( - TestServices.StyledWindow.With( - renderInterface: new NullRenderingPlatform(), - dispatcherImpl: new NullThreadingPlatform(), - standardCursorFactory: new NullCursorFactory())); + _app = UnitTestApplication.Start(TestServices.StyledWindow); _root = new TestRoot(true, null) { diff --git a/tests/Avalonia.Benchmarks/NullCursorFactory.cs b/tests/Avalonia.Benchmarks/NullCursorFactory.cs deleted file mode 100644 index 9aeb353151..0000000000 --- a/tests/Avalonia.Benchmarks/NullCursorFactory.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using Avalonia.Input; -using Avalonia.Platform; - -namespace Avalonia.Benchmarks -{ - internal class NullCursorFactory : ICursorFactory - { - public ICursorImpl CreateCursor(IBitmapImpl cursor, PixelPoint hotSpot) => new NullCursorImpl(); - ICursorImpl ICursorFactory.GetCursor(StandardCursorType cursorType) => new NullCursorImpl(); - - private class NullCursorImpl : ICursorImpl - { - public void Dispose() { } - } - } -} diff --git a/tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs b/tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs deleted file mode 100644 index 40d504a0ac..0000000000 --- a/tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.Rendering.SceneGraph; -using Avalonia.Utilities; -using Avalonia.Media.Imaging; - -namespace Avalonia.Benchmarks -{ - internal class NullDrawingContextImpl : IDrawingContextImpl - { - public void Dispose() - { - } - - public Matrix Transform { get; set; } - - public void Clear(Color color) - { - } - - public void DrawBitmap(IRef source, double opacity, Rect sourceRect, Rect destRect, - BitmapInterpolationMode bitmapInterpolationMode = BitmapInterpolationMode.Default) - { - } - - public void DrawBitmap(IRef source, IBrush opacityMask, Rect opacityMaskRect, Rect destRect) - { - } - - public void DrawLine(IPen pen, Point p1, Point p2) - { - } - - public void DrawGeometry(IBrush brush, IPen pen, IGeometryImpl geometry) - { - } - - public void DrawRectangle(IBrush brush, IPen pen, RoundedRect rect, BoxShadows boxShadows = default) - { - } - - public void DrawEllipse(IBrush brush, IPen pen, Rect rect) - { - } - - public void DrawGlyphRun(IBrush foreground, IRef glyphRun) - { - } - - public IDrawingContextLayerImpl CreateLayer(Size size) - { - return null; - } - - public void PushClip(Rect clip) - { - } - - public void PushClip(RoundedRect clip) - { - } - - public void PopClip() - { - } - - public void PushOpacity(double opacity, Rect bounds) - { - } - - public void PopOpacity() - { - } - - public void PushOpacityMask(IBrush mask, Rect bounds) - { - } - - public void PopOpacityMask() - { - } - - public void PushGeometryClip(IGeometryImpl clip) - { - } - - public void PopGeometryClip() - { - } - - public void PushBitmapBlendMode(BitmapBlendingMode blendingMode) - { - } - - public void PopBitmapBlendMode() - { - } - - public void Custom(ICustomDrawOperation custom) - { - } - - public object GetFeature(Type t) => null; - } -} diff --git a/tests/Avalonia.Benchmarks/NullGlyphRun.cs b/tests/Avalonia.Benchmarks/NullGlyphRun.cs deleted file mode 100644 index 5b584f302d..0000000000 --- a/tests/Avalonia.Benchmarks/NullGlyphRun.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using Avalonia.Platform; - -namespace Avalonia.Benchmarks -{ - internal class NullGlyphRun : IGlyphRunImpl - { - public Rect Bounds => default; - - public Point BaselineOrigin => default; - - public void Dispose() - { - } - - public IReadOnlyList GetIntersections(float lowerBound, float upperBound) - { - return null; - } - } -} diff --git a/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs b/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs deleted file mode 100644 index d40abd9f47..0000000000 --- a/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.UnitTests; -using Avalonia.Media.Imaging; -using Avalonia.Media.TextFormatting; -using Microsoft.Diagnostics.Runtime; - -namespace Avalonia.Benchmarks -{ - internal class NullRenderingPlatform : IPlatformRenderInterface, IPlatformRenderInterfaceContext - { - public IGeometryImpl CreateEllipseGeometry(Rect rect) - { - return new MockStreamGeometryImpl(); - } - - public IGeometryImpl CreateLineGeometry(Point p1, Point p2) - { - return new MockStreamGeometryImpl(); - } - - public IGeometryImpl CreateRectangleGeometry(Rect rect) - { - return new MockStreamGeometryImpl(); - } - - public IStreamGeometryImpl CreateStreamGeometry() - { - return new MockStreamGeometryImpl(); - } - - public IGeometryImpl CreateGeometryGroup(FillRule fillRule, IReadOnlyList children) - { - throw new NotImplementedException(); - } - - public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, IGeometryImpl g1, IGeometryImpl g2) - { - throw new NotImplementedException(); - } - - public IRenderTarget CreateRenderTarget(IEnumerable surfaces) - { - throw new NotImplementedException(); - } - - public bool IsLost => false; - - public object TryGetFeature(Type featureType) => null; - - public IRenderTargetBitmapImpl CreateRenderTargetBitmap(PixelSize size, Vector dpi) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl CreateWriteableBitmap(PixelSize size, Vector dpi, PixelFormat format, AlphaFormat alphaFormat) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmap(string fileName) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmap(Stream stream) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmapToWidth(Stream stream, int width, - BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmapToHeight(Stream stream, int height, - BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmap(string fileName) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmap(Stream stream) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmap(PixelFormat format, AlphaFormat alphaFormat, IntPtr data, PixelSize size, Vector dpi, int stride) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmapToWidth(Stream stream, int width, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmapToHeight(Stream stream, int height, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IBitmapImpl ResizeBitmap(IBitmapImpl bitmapImpl, PixelSize destinationSize, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IFontManagerImpl CreateFontManager() - { - return new MockFontManagerImpl(); - } - - public IGeometryImpl BuildGlyphRunGeometry(GlyphRun glyphRun) - { - return new MockStreamGeometryImpl(); - } - - public IGlyphRunImpl CreateGlyphRun(IGlyphTypeface glyphTypeface, double fontRenderingEmSize, - IReadOnlyList glyphInfos, Point baselineOrigin) - { - return new MockGlyphRun(glyphInfos); - } - - public IPlatformRenderInterfaceContext CreateBackendContext(IPlatformGraphicsContext graphicsContext) - { - return this; - } - - public bool SupportsIndividualRoundRects => true; - - public AlphaFormat DefaultAlphaFormat => AlphaFormat.Premul; - - public PixelFormat DefaultPixelFormat => PixelFormat.Rgba8888; - public bool IsSupportedBitmapPixelFormat(PixelFormat format) => true; - - public void Dispose() - { - - } - } -} diff --git a/tests/Avalonia.Benchmarks/NullThreadingPlatform.cs b/tests/Avalonia.Benchmarks/NullThreadingPlatform.cs deleted file mode 100644 index 1b5b60031c..0000000000 --- a/tests/Avalonia.Benchmarks/NullThreadingPlatform.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Reactive.Disposables; -using System.Threading; -using Avalonia.Platform; -using Avalonia.Threading; - -namespace Avalonia.Benchmarks -{ - internal class NullThreadingPlatform : IDispatcherImpl - { - public void Signal() - { - } - - public void UpdateTimer(long? dueTimeInMs) - { - } - - public bool CurrentThreadIsLoopThread => true; - -#pragma warning disable CS0067 - public event Action Signaled; - public event Action Timer; - public long Now => 0; -#pragma warning restore CS0067 - } -} diff --git a/tests/Avalonia.Benchmarks/Rendering/ShapeRendering.cs b/tests/Avalonia.Benchmarks/Rendering/ShapeRendering.cs index 2905b1e464..ba217017d1 100644 --- a/tests/Avalonia.Benchmarks/Rendering/ShapeRendering.cs +++ b/tests/Avalonia.Benchmarks/Rendering/ShapeRendering.cs @@ -1,4 +1,5 @@ using Avalonia.Controls.Shapes; +using Avalonia.Headless; using Avalonia.Media; using Avalonia.Platform; using BenchmarkDotNet.Attributes; @@ -21,9 +22,9 @@ namespace Avalonia.Benchmarks.Rendering _lineFill = new Line { Fill = new SolidColorBrush() }; _lineFillAndStroke = new Line { Stroke = new SolidColorBrush(), Fill = new SolidColorBrush() }; - _drawingContext = new PlatformDrawingContext(new NullDrawingContextImpl(), true); + _drawingContext = new PlatformDrawingContext(new HeadlessPlatformRenderInterface.HeadlessDrawingContextStub(), true); - AvaloniaLocator.CurrentMutable.Bind().ToConstant(new NullRenderingPlatform()); + AvaloniaLocator.CurrentMutable.Bind().ToConstant(new HeadlessPlatformRenderInterface()); } [Benchmark] diff --git a/tests/Avalonia.Benchmarks/Styling/ControlTheme_Change.cs b/tests/Avalonia.Benchmarks/Styling/ControlTheme_Change.cs index 00080e3b82..9e79b25488 100644 --- a/tests/Avalonia.Benchmarks/Styling/ControlTheme_Change.cs +++ b/tests/Avalonia.Benchmarks/Styling/ControlTheme_Change.cs @@ -20,10 +20,7 @@ namespace Avalonia.Benchmarks.Styling public ControlTheme_Change() { - _app = UnitTestApplication.Start( - TestServices.StyledWindow.With( - renderInterface: new NullRenderingPlatform(), - dispatcherImpl: new NullThreadingPlatform())); + _app = UnitTestApplication.Start(TestServices.StyledWindow); // Simulate an application with a lot of styles by creating a tree of nested panels, // each with a bunch of styles applied. diff --git a/tests/Avalonia.Benchmarks/Styling/ResourceBenchmarks.cs b/tests/Avalonia.Benchmarks/Styling/ResourceBenchmarks.cs index b044bcde59..e2bc018142 100644 --- a/tests/Avalonia.Benchmarks/Styling/ResourceBenchmarks.cs +++ b/tests/Avalonia.Benchmarks/Styling/ResourceBenchmarks.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Platform; using Avalonia.Styling; using Avalonia.UnitTests; @@ -20,12 +21,8 @@ namespace Avalonia.Benchmarks.Styling assetLoader: new AssetLoader(), globalClock: new MockGlobalClock(), platform: new AppBuilder().RuntimePlatform, - renderInterface: new MockPlatformRenderInterface(), standardCursorFactory: Mock.Of(), theme: () => CreateTheme(), - dispatcherImpl: new NullThreadingPlatform(), - fontManagerImpl: new MockFontManagerImpl(), - textShaperImpl: new MockTextShaperImpl(), windowingPlatform: new MockWindowingPlatform()); return UnitTestApplication.Start(services); diff --git a/tests/Avalonia.Benchmarks/Styling/Style_Apply_Detach_Complex.cs b/tests/Avalonia.Benchmarks/Styling/Style_Apply_Detach_Complex.cs index 307f15a6c0..03f3f47276 100644 --- a/tests/Avalonia.Benchmarks/Styling/Style_Apply_Detach_Complex.cs +++ b/tests/Avalonia.Benchmarks/Styling/Style_Apply_Detach_Complex.cs @@ -17,10 +17,7 @@ namespace Avalonia.Benchmarks.Styling public Style_Apply_Detach_Complex() { - _app = UnitTestApplication.Start( - TestServices.StyledWindow.With( - renderInterface: new NullRenderingPlatform(), - dispatcherImpl: new NullThreadingPlatform())); + _app = UnitTestApplication.Start(TestServices.StyledWindow); // Simulate an application with a lot of styles by creating a tree of nested panels, // each with a bunch of styles applied. diff --git a/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs b/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs index 22ca9d8c6d..bc90abad7e 100644 --- a/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs +++ b/tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs @@ -30,10 +30,7 @@ public class HugeTextLayout : IDisposable { _manySmallStrings = Enumerable.Range(0, 1000).Select(_ => RandomString(s_rand.Next(2, 15))).ToArray(); - var testServices = TestServices.StyledWindow.With( - renderInterface: new NullRenderingPlatform(), - dispatcherImpl: new NullThreadingPlatform(), - standardCursorFactory: new NullCursorFactory()); + var testServices = TestServices.StyledWindow; if (s_useSkia) { diff --git a/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs b/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs index 8eadb3a3f0..9ec3cb9b9a 100644 --- a/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs +++ b/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs @@ -45,9 +45,6 @@ namespace Avalonia.Benchmarks.Themes private static IDisposable CreateApp() { var services = new TestServices( - renderInterface: new NullRenderingPlatform(), - dispatcherImpl: new NullThreadingPlatform(), - standardCursorFactory: new NullCursorFactory(), theme: () => LoadFluentTheme()); return UnitTestApplication.Start(services); diff --git a/tests/Avalonia.Controls.UnitTests/DatePickerTests.cs b/tests/Avalonia.Controls.UnitTests/DatePickerTests.cs index 3b2ca51976..6c9aefc5a7 100644 --- a/tests/Avalonia.Controls.UnitTests/DatePickerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DatePickerTests.cs @@ -2,6 +2,7 @@ using System.Linq; using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; +using Avalonia.Headless; using Avalonia.Platform; using Avalonia.UnitTests; using Avalonia.VisualTree; @@ -203,10 +204,10 @@ namespace Avalonia.Controls.UnitTests } private static TestServices Services => TestServices.MockThreadingInterface.With( - fontManagerImpl: new MockFontManagerImpl(), + fontManagerImpl: new HeadlessFontManagerStub(), standardCursorFactory: Mock.Of(), - textShaperImpl: new MockTextShaperImpl(), - renderInterface: new MockPlatformRenderInterface()); + textShaperImpl: new HeadlessTextShaperStub(), + renderInterface: new HeadlessPlatformRenderInterface()); private static IControlTemplate CreateTemplate() { diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 5e741cdc1d..c713147f59 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -9,6 +9,7 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Headless; using Avalonia.Input; using Avalonia.Layout; using Avalonia.LogicalTree; @@ -1022,12 +1023,12 @@ namespace Avalonia.Controls.UnitTests return UnitTestApplication.Start( TestServices.MockThreadingInterface.With( focusManager: new FocusManager(), - fontManagerImpl: new MockFontManagerImpl(), + fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: new KeyboardNavigationHandler(), inputManager: new InputManager(), - renderInterface: new MockPlatformRenderInterface(), - textShaperImpl: new MockTextShaperImpl())); + renderInterface: new HeadlessPlatformRenderInterface(), + textShaperImpl: new HeadlessTextShaperStub())); } private class ItemsControlWithContainer : ItemsControl, IStyleable diff --git a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs index 9fd56dec4a..f960d41c95 100644 --- a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs @@ -7,6 +7,7 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Headless; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Layout; @@ -892,16 +893,16 @@ namespace Avalonia.Controls.UnitTests keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: new KeyboardNavigationHandler(), inputManager: new InputManager(), - renderInterface: new MockPlatformRenderInterface(), - fontManagerImpl: new MockFontManagerImpl(), - textShaperImpl: new MockTextShaperImpl(), + renderInterface: new HeadlessPlatformRenderInterface(), + fontManagerImpl: new HeadlessFontManagerStub(), + textShaperImpl: new HeadlessTextShaperStub(), standardCursorFactory: Mock.Of()); private static TestServices Services => TestServices.MockThreadingInterface.With( - renderInterface: new MockPlatformRenderInterface(), + renderInterface: new HeadlessPlatformRenderInterface(), standardCursorFactory: Mock.Of(), - textShaperImpl: new MockTextShaperImpl(), - fontManagerImpl: new MockFontManagerImpl()); + textShaperImpl: new HeadlessTextShaperStub(), + fontManagerImpl: new HeadlessFontManagerStub()); private static IControlTemplate CreateTemplate() { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index daebc1e709..43cbbdecda 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -10,6 +10,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Selection; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Headless; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Styling; @@ -1339,12 +1340,12 @@ namespace Avalonia.Controls.UnitTests.Primitives return UnitTestApplication.Start( TestServices.MockThreadingInterface.With( focusManager: new FocusManager(), - fontManagerImpl: new MockFontManagerImpl(), + fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: new KeyboardNavigationHandler(), inputManager: new InputManager(), - renderInterface: new MockPlatformRenderInterface(), - textShaperImpl: new MockTextShaperImpl())); + renderInterface: new HeadlessPlatformRenderInterface(), + textShaperImpl: new HeadlessTextShaperStub())); } private class TestSelector : SelectingItemsControl diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index d71abe5a67..25dcd01f53 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -6,6 +6,7 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Headless; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Layout; @@ -1104,14 +1105,14 @@ namespace Avalonia.Controls.UnitTests keyboardNavigation: new KeyboardNavigationHandler(), inputManager: new InputManager(), standardCursorFactory: Mock.Of(), - textShaperImpl: new MockTextShaperImpl(), - fontManagerImpl: new MockFontManagerImpl()); + textShaperImpl: new HeadlessTextShaperStub(), + fontManagerImpl: new HeadlessFontManagerStub()); private static TestServices Services => TestServices.MockThreadingInterface.With( standardCursorFactory: Mock.Of(), - renderInterface: new MockPlatformRenderInterface(), - textShaperImpl: new MockTextShaperImpl(), - fontManagerImpl: new MockFontManagerImpl()); + renderInterface: new HeadlessPlatformRenderInterface(), + textShaperImpl: new HeadlessTextShaperStub(), + fontManagerImpl: new HeadlessFontManagerStub()); private IControlTemplate CreateTemplate() { diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs index bb952ca7e0..9a9c4d352e 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs @@ -6,6 +6,7 @@ using System.Linq; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Headless; using Avalonia.Markup.Data; using Avalonia.Platform; using Avalonia.UnitTests; @@ -87,8 +88,8 @@ namespace Avalonia.Controls.UnitTests private static TestServices Services => TestServices.MockThreadingInterface.With( standardCursorFactory: Mock.Of(), - textShaperImpl: new MockTextShaperImpl(), - fontManagerImpl: new MockFontManagerImpl()); + textShaperImpl: new HeadlessTextShaperStub(), + fontManagerImpl: new HeadlessFontManagerStub()); private static IControlTemplate CreateTemplate() { diff --git a/tests/Avalonia.Controls.UnitTests/TimePickerTests.cs b/tests/Avalonia.Controls.UnitTests/TimePickerTests.cs index e0bf230d99..1449f3f759 100644 --- a/tests/Avalonia.Controls.UnitTests/TimePickerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TimePickerTests.cs @@ -2,6 +2,7 @@ using System.Linq; using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; +using Avalonia.Headless; using Avalonia.Platform; using Avalonia.UnitTests; using Avalonia.VisualTree; @@ -99,10 +100,10 @@ namespace Avalonia.Controls.UnitTests } private static TestServices Services => TestServices.MockThreadingInterface.With( - fontManagerImpl: new MockFontManagerImpl(), + fontManagerImpl: new HeadlessFontManagerStub(), standardCursorFactory: Mock.Of(), - textShaperImpl: new MockTextShaperImpl(), - renderInterface: new MockPlatformRenderInterface()); + textShaperImpl: new HeadlessTextShaperStub(), + renderInterface: new HeadlessPlatformRenderInterface()); private static IControlTemplate CreateTemplate() { diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index baf8ad5c0e..e9ccda7083 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -9,6 +9,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Data.Core; +using Avalonia.Headless; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Layout; @@ -1694,12 +1695,12 @@ namespace Avalonia.Controls.UnitTests return UnitTestApplication.Start( TestServices.MockThreadingInterface.With( focusManager: new FocusManager(), - fontManagerImpl: new MockFontManagerImpl(), + fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: new KeyboardNavigationHandler(), inputManager: new InputManager(), - renderInterface: new MockPlatformRenderInterface(), - textShaperImpl: new MockTextShaperImpl())); + renderInterface: new HeadlessPlatformRenderInterface(), + textShaperImpl: new HeadlessTextShaperStub())); } private class Node : NotifyingBase diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs index 2c20b7a0b7..13c5241f69 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs @@ -402,6 +402,8 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml [Fact] public void Style_Can_Use_NthChild_Selector_With_ItemsRepeater() { + GC.KeepAlive(typeof(ItemsRepeater)); + using (UnitTestApplication.Start(TestServices.StyledWindow)) { var xaml = @" diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs index 8ca16bd873..62f1cd2032 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Headless; using Avalonia.Media; using Avalonia.UnitTests; using SkiaSharp; @@ -90,7 +91,7 @@ namespace Avalonia.Skia.UnitTests.Media [Fact] public void Should_Only_Try_To_Create_GlyphTypeface_Once() { - var fontManagerImpl = new MockFontManagerImpl(); + var fontManagerImpl = new HeadlessFontManagerStub(); using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: fontManagerImpl))) { diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index c84fcaaa9a..543dd0805e 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Runtime.InteropServices; +using Avalonia.Headless; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index d605ecbfda..c31c7ea55f 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Runtime.InteropServices; +using Avalonia.Headless; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.UnitTests; diff --git a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj index aec6647226..0d49f78dd9 100644 --- a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj +++ b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/Avalonia.UnitTests/ImmediateDispatcher.cs b/tests/Avalonia.UnitTests/ImmediateDispatcher.cs deleted file mode 100644 index f6f3de3bc6..0000000000 --- a/tests/Avalonia.UnitTests/ImmediateDispatcher.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Avalonia.Threading; - -namespace Avalonia.UnitTests -{ - /// - /// Immediately invokes dispatched jobs on the current thread. - /// - public class ImmediateDispatcher : IDispatcher - { - /// - public bool CheckAccess() - { - return true; - } - - /// - public void Post(Action action, DispatcherPriority priority) - { - action(); - } - - /// - public void Post(SendOrPostCallback action, object arg, DispatcherPriority priority) - { - action(arg); - } - - /// - public Task InvokeAsync(Action action, DispatcherPriority priority) - { - action(); - return Task.CompletedTask; - } - - /// - public Task InvokeAsync(Func function, DispatcherPriority priority) - { - var result = function(); - return Task.FromResult(result); - } - - /// - public Task InvokeAsync(Func function, DispatcherPriority priority) - { - return function(); - } - - /// - public Task InvokeAsync(Func> function, DispatcherPriority priority) - { - return function(); - } - - /// - public void VerifyAccess() - { - } - } -} diff --git a/tests/Avalonia.UnitTests/MockFontManagerImpl.cs b/tests/Avalonia.UnitTests/MockFontManagerImpl.cs deleted file mode 100644 index 16423884b3..0000000000 --- a/tests/Avalonia.UnitTests/MockFontManagerImpl.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using Avalonia.Media; -using Avalonia.Platform; - -namespace Avalonia.UnitTests -{ - public class MockFontManagerImpl : IFontManagerImpl - { - private readonly string _defaultFamilyName; - - public MockFontManagerImpl(string defaultFamilyName = "Default") - { - _defaultFamilyName = defaultFamilyName; - } - - public int TryCreateGlyphTypefaceCount { get; private set; } - - public string GetDefaultFontFamilyName() - { - return _defaultFamilyName; - } - - string[] IFontManagerImpl.GetInstalledFontFamilyNames(bool checkForUpdates) - { - return new[] { _defaultFamilyName }; - } - - public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, - FontStretch fontStretch, - CultureInfo culture, out Typeface fontKey) - { - fontKey = new Typeface(_defaultFamilyName); - - return false; - } - - public virtual bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight, - FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface glyphTypeface) - { - glyphTypeface = null; - - TryCreateGlyphTypefaceCount++; - - if (familyName == "Unknown") - { - return false; - } - - glyphTypeface = new MockGlyphTypeface(); - - return true; - } - - public virtual bool TryCreateGlyphTypeface(Stream stream, out IGlyphTypeface glyphTypeface) - { - glyphTypeface = new MockGlyphTypeface(); - - return true; - } - } -} diff --git a/tests/Avalonia.UnitTests/MockGlyphRun.cs b/tests/Avalonia.UnitTests/MockGlyphRun.cs deleted file mode 100644 index 4561d3b3f2..0000000000 --- a/tests/Avalonia.UnitTests/MockGlyphRun.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using Avalonia.Media.TextFormatting; -using Avalonia.Platform; - -namespace Avalonia.UnitTests -{ - public class MockGlyphRun : IGlyphRunImpl - { - public MockGlyphRun(IReadOnlyList glyphInfos) - { - var width = 0.0; - - for (var i = 0; i < glyphInfos.Count; ++i) - { - width += glyphInfos[i].GlyphAdvance; - } - - Bounds = new Rect(new Size(width, 10)); - } - - public Rect Bounds { get; } - - public Point BaselineOrigin => new Point(0, 8); - - public void Dispose() - { - } - - public IReadOnlyList GetIntersections(float lowerBound, float upperBound) - => Array.Empty(); - } -} diff --git a/tests/Avalonia.UnitTests/MockGlyphTypeface.cs b/tests/Avalonia.UnitTests/MockGlyphTypeface.cs deleted file mode 100644 index 5fcee7f515..0000000000 --- a/tests/Avalonia.UnitTests/MockGlyphTypeface.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using Avalonia.Media; - -namespace Avalonia.UnitTests -{ - public class MockGlyphTypeface : IGlyphTypeface - { - public FontMetrics Metrics => new FontMetrics - { - DesignEmHeight = 10, - Ascent = 2, - Descent = 10, - IsFixedPitch = true - }; - - public int GlyphCount => 1337; - - public FontSimulations FontSimulations => throw new NotImplementedException(); - - public string FamilyName => "$Default"; - - public FontWeight Weight { get; } - - public FontStyle Style { get; } - - public FontStretch Stretch { get; } - - public ushort GetGlyph(uint codepoint) - { - return (ushort)codepoint; - } - - public ushort[] GetGlyphs(ReadOnlySpan codepoints) - { - return new ushort[codepoints.Length]; - } - - public int GetGlyphAdvance(ushort glyph) - { - return 8; - } - - public bool TryGetGlyph(uint codepoint, out ushort glyph) - { - glyph = 8; - - return true; - } - - public int[] GetGlyphAdvances(ReadOnlySpan glyphs) - { - var advances = new int[glyphs.Length]; - - for (var i = 0; i < advances.Length; i++) - { - advances[i] = 8; - } - - return advances; - } - - public void Dispose() { } - - public bool TryGetTable(uint tag, out byte[] table) - { - table = null; - return false; - } - - public bool TryGetGlyphMetrics(ushort glyph, out GlyphMetrics metrics) - { - metrics = new GlyphMetrics - { - Width = 10, - Height = 10 - }; - - return true; - } - } -} diff --git a/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs b/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs deleted file mode 100644 index 720755f2b0..0000000000 --- a/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.Media.Imaging; -using Avalonia.Media.TextFormatting; -using Avalonia.Rendering; -using Moq; - -namespace Avalonia.UnitTests -{ - public class MockPlatformRenderInterface : IPlatformRenderInterface, IPlatformRenderInterfaceContext - { - public IGeometryImpl CreateEllipseGeometry(Rect rect) - { - return Mock.Of(); - } - - public IGeometryImpl CreateLineGeometry(Point p1, Point p2) - { - return Mock.Of(); - } - - public IGeometryImpl CreateRectangleGeometry(Rect rect) - { - return Mock.Of(x => x.Bounds == rect); - } - - class MockRenderTarget : IRenderTarget - { - public void Dispose() - { - - } - - public IDrawingContextImpl CreateDrawingContext() - { - var m = new Mock(); - m.Setup(c => c.CreateLayer(It.IsAny())) - .Returns(() => - { - var r = new Mock(); - r.Setup(r => r.CreateDrawingContext()) - .Returns(CreateDrawingContext()); - return r.Object; - } - ); - return m.Object; - - } - - public bool IsCorrupted => false; - } - - public IRenderTarget CreateRenderTarget(IEnumerable surfaces) - { - return new MockRenderTarget(); - } - - public bool IsLost => false; - - public object TryGetFeature(Type featureType) => null; - - public IRenderTargetBitmapImpl CreateRenderTargetBitmap(PixelSize size, Vector dpi) - { - return Mock.Of(); - } - - public IStreamGeometryImpl CreateStreamGeometry() - { - return new MockStreamGeometryImpl(); - } - - public IGeometryImpl CreateGeometryGroup(FillRule fillRule, IReadOnlyList children) - { - return Mock.Of(); - } - - public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, IGeometryImpl g1, IGeometryImpl g2) - { - return Mock.Of(); - } - - public IWriteableBitmapImpl CreateWriteableBitmap( - PixelSize size, - Vector dpi, - PixelFormat format, - AlphaFormat alphaFormat) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmap(Stream stream) - { - return Mock.Of(); - } - - public IWriteableBitmapImpl LoadWriteableBitmapToWidth(Stream stream, int width, - BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmapToHeight(Stream stream, int height, - BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmap(string fileName) - { - throw new NotImplementedException(); - } - - public IWriteableBitmapImpl LoadWriteableBitmap(Stream stream) - { - throw new NotImplementedException(); - } - - public IBitmapImpl LoadBitmap(string fileName) - { - return Mock.Of(); - } - - public IBitmapImpl LoadBitmapToWidth(Stream stream, int width, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - return Mock.Of(); - } - - public IBitmapImpl LoadBitmapToHeight(Stream stream, int height, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - return Mock.Of(); - } - - public IBitmapImpl ResizeBitmap(IBitmapImpl bitmapImpl, PixelSize destinationSize, BitmapInterpolationMode interpolationMode = BitmapInterpolationMode.HighQuality) - { - return Mock.Of(); - } - - public IBitmapImpl LoadBitmap( - PixelFormat format, - AlphaFormat alphaFormat, - IntPtr data, - PixelSize size, - Vector dpi, - int stride) - { - throw new NotImplementedException(); - } - - public IGlyphRunImpl CreateGlyphRun(IGlyphTypeface glyphTypeface, double fontRenderingEmSize, - IReadOnlyList glyphInfos, Point baselineOrigin) - { - return new MockGlyphRun(glyphInfos); - } - - public IPlatformRenderInterfaceContext CreateBackendContext(IPlatformGraphicsContext graphicsContext) => this; - - public IGeometryImpl BuildGlyphRunGeometry(GlyphRun glyphRun) - { - return Mock.Of(); - } - - public IGlyphRunBuffer AllocateGlyphRun(IGlyphTypeface glyphTypeface, float fontRenderingEmSize, int length) - { - return Mock.Of(); - } - - public IHorizontalGlyphRunBuffer AllocateHorizontalGlyphRun(IGlyphTypeface glyphTypeface, float fontRenderingEmSize, int length) - { - return Mock.Of(); - } - - public IPositionedGlyphRunBuffer AllocatePositionedGlyphRun(IGlyphTypeface glyphTypeface, float fontRenderingEmSize, int length) - { - return Mock.Of(); - } - - public bool SupportsIndividualRoundRects { get; set; } - - public AlphaFormat DefaultAlphaFormat => AlphaFormat.Premul; - - public PixelFormat DefaultPixelFormat => PixelFormat.Rgba8888; - public bool IsSupportedBitmapPixelFormat(PixelFormat format) => true; - - public void Dispose() - { - } - } -} diff --git a/tests/Avalonia.UnitTests/MockStreamGeometryImpl.cs b/tests/Avalonia.UnitTests/MockStreamGeometryImpl.cs deleted file mode 100644 index 9d039a386e..0000000000 --- a/tests/Avalonia.UnitTests/MockStreamGeometryImpl.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.Collections.Generic; -using Avalonia.Media; -using Avalonia.Platform; - -namespace Avalonia.UnitTests -{ - public class MockStreamGeometryImpl : IStreamGeometryImpl, ITransformedGeometryImpl - { - private MockStreamGeometryContext _context; - - public MockStreamGeometryImpl() - { - Transform = Matrix.Identity; - _context = new MockStreamGeometryContext(); - } - - public MockStreamGeometryImpl(Matrix transform) - { - Transform = transform; - _context = new MockStreamGeometryContext(); - } - - private MockStreamGeometryImpl(Matrix transform, MockStreamGeometryContext context) - { - Transform = transform; - _context = context; - } - - public IGeometryImpl SourceGeometry { get; } - - public Rect Bounds => _context.CalculateBounds(); - - public double ContourLength { get; } - - public Matrix Transform { get; } - - public IStreamGeometryImpl Clone() - { - return this; - } - - public void Dispose() - { - } - - public bool FillContains(Point point) - { - return _context.FillContains(point); - } - - public bool StrokeContains(IPen pen, Point point) - { - return false; - } - - public Rect GetRenderBounds(IPen pen) => Bounds; - - public IGeometryImpl Intersect(IGeometryImpl geometry) - { - return new MockStreamGeometryImpl(Transform); - } - - public IStreamGeometryContextImpl Open() - { - return _context; - } - - public ITransformedGeometryImpl WithTransform(Matrix transform) - { - return new MockStreamGeometryImpl(transform, _context); - } - - public bool TryGetPointAtDistance(double distance, out Point point) - { - point = new Point(); - return false; - } - - public bool TryGetPointAndTangentAtDistance(double distance, out Point point, out Point tangent) - { - point = new Point(); - tangent = new Point(); - return false; - } - - public bool TryGetSegment(double startDistance, double stopDistance, bool startOnBeginFigure, out IGeometryImpl segmentGeometry) - { - segmentGeometry = null; - return false; - } - - class MockStreamGeometryContext : IStreamGeometryContextImpl - { - private List points = new List(); - public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, SweepDirection sweepDirection) - { - } - - public void BeginFigure(Point startPoint, bool isFilled) - { - points.Add(startPoint); - } - - public Rect CalculateBounds() - { - var left = double.MaxValue; - var right = double.MinValue; - var top = double.MaxValue; - var bottom = double.MinValue; - - foreach (var p in points) - { - left = Math.Min(p.X, left); - right = Math.Max(p.X, right); - top = Math.Min(p.Y, top); - bottom = Math.Max(p.Y, bottom); - } - - return new Rect(new Point(left, top), new Point(right, bottom)); - } - - public void CubicBezierTo(Point point1, Point point2, Point point3) - { - } - - public void Dispose() - { - } - - public void EndFigure(bool isClosed) - { - } - - public void LineTo(Point point) - { - points.Add(point); - } - - public void QuadraticBezierTo(Point control, Point endPoint) - { - throw new NotImplementedException(); - } - - public void SetFillRule(FillRule fillRule) - { - } - - public bool FillContains(Point point) - { - // Use the algorithm from https://www.blackpawn.com/texts/pointinpoly/default.html - // to determine if the point is in the geometry (since it will always be convex in this situation) - for (int i = 0; i < points.Count; i++) - { - var a = points[i]; - var b = points[(i + 1) % points.Count]; - var c = points[(i + 2) % points.Count]; - - Vector v0 = c - a; - Vector v1 = b - a; - Vector v2 = point - a; - - var dot00 = v0 * v0; - var dot01 = v0 * v1; - var dot02 = v0 * v2; - var dot11 = v1 * v1; - var dot12 = v1 * v2; - - - var invDenom = 1 / (dot00 * dot11 - dot01 * dot01); - var u = (dot11 * dot02 - dot01 * dot12) * invDenom; - var v = (dot00 * dot12 - dot01 * dot02) * invDenom; - if ((u >= 0) && (v >= 0) && (u + v < 1)) return true; - } - return false; - } - } - } -} diff --git a/tests/Avalonia.UnitTests/MockTextShaperImpl.cs b/tests/Avalonia.UnitTests/MockTextShaperImpl.cs deleted file mode 100644 index b5f4777192..0000000000 --- a/tests/Avalonia.UnitTests/MockTextShaperImpl.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using Avalonia.Media.TextFormatting; -using Avalonia.Media.TextFormatting.Unicode; -using Avalonia.Platform; - -namespace Avalonia.UnitTests -{ - public class MockTextShaperImpl : ITextShaperImpl - { - public ShapedBuffer ShapeText(ReadOnlyMemory text, TextShaperOptions options) - { - var typeface = options.Typeface; - var fontRenderingEmSize = options.FontRenderingEmSize; - var bidiLevel = options.BidiLevel; - var shapedBuffer = new ShapedBuffer(text, text.Length, typeface, fontRenderingEmSize, bidiLevel); - var textSpan = text.Span; - var textStartIndex = TextTestHelper.GetStartCharIndex(text); - - for (var i = 0; i < shapedBuffer.Length;) - { - var glyphCluster = i + textStartIndex; - - var codepoint = Codepoint.ReadAt(textSpan, i, out var count); - - var glyphIndex = typeface.GetGlyph(codepoint); - - for (var j = 0; j < count; ++j) - { - shapedBuffer[i + j] = new GlyphInfo(glyphIndex, glyphCluster, 10); - } - - i += count; - } - - return shapedBuffer; - } - } -} diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs index 800abbc2c7..23dc0de964 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -13,6 +13,7 @@ using System.Collections.Generic; using Avalonia.Controls; using System.Reflection; using Avalonia.Animation; +using Avalonia.Headless; using Avalonia.Threading; namespace Avalonia.UnitTests @@ -22,25 +23,25 @@ namespace Avalonia.UnitTests public static readonly TestServices StyledWindow = new TestServices( assetLoader: new AssetLoader(), platform: new StandardRuntimePlatform(), - renderInterface: new MockPlatformRenderInterface(), - standardCursorFactory: Mock.Of(), + renderInterface: new HeadlessPlatformRenderInterface(), + standardCursorFactory: new HeadlessCursorFactoryStub(), theme: () => CreateSimpleTheme(), - dispatcherImpl: Mock.Of(x => x.CurrentThreadIsLoopThread == true), - fontManagerImpl: new MockFontManagerImpl(), - textShaperImpl: new MockTextShaperImpl(), + dispatcherImpl: new NullDispatcherImpl(), + fontManagerImpl: new HeadlessFontManagerStub(), + textShaperImpl: new HeadlessTextShaperStub(), windowingPlatform: new MockWindowingPlatform()); public static readonly TestServices MockPlatformRenderInterface = new TestServices( assetLoader: new AssetLoader(), - renderInterface: new MockPlatformRenderInterface(), - fontManagerImpl: new MockFontManagerImpl(), - textShaperImpl: new MockTextShaperImpl()); + renderInterface: new HeadlessPlatformRenderInterface(), + fontManagerImpl: new HeadlessFontManagerStub(), + textShaperImpl: new HeadlessTextShaperStub()); public static readonly TestServices MockPlatformWrapper = new TestServices( platform: Mock.Of()); public static readonly TestServices MockThreadingInterface = new TestServices( - dispatcherImpl: Mock.Of(x => x.CurrentThreadIsLoopThread == true)); + dispatcherImpl: new NullDispatcherImpl()); public static readonly TestServices MockWindowingPlatform = new TestServices( windowingPlatform: new MockWindowingPlatform()); @@ -51,13 +52,13 @@ namespace Avalonia.UnitTests keyboardNavigation: new KeyboardNavigationHandler(), inputManager: new InputManager(), assetLoader: new AssetLoader(), - renderInterface: new MockPlatformRenderInterface(), - fontManagerImpl: new MockFontManagerImpl(), - textShaperImpl: new MockTextShaperImpl()); + renderInterface: new HeadlessPlatformRenderInterface(), + fontManagerImpl: new HeadlessFontManagerStub(), + textShaperImpl: new HeadlessTextShaperStub()); public static readonly TestServices TextServices = new TestServices( assetLoader: new AssetLoader(), - renderInterface: new MockPlatformRenderInterface(), + renderInterface: new HeadlessPlatformRenderInterface(), fontManagerImpl: new HarfBuzzFontManagerImpl(), textShaperImpl: new HarfBuzzTextShaperImpl()); @@ -158,12 +159,5 @@ namespace Avalonia.UnitTests { return new SimpleTheme(); } - - private static IPlatformRenderInterface CreateRenderInterfaceMock() - { - return Mock.Of(x => - x.CreateStreamGeometry() == Mock.Of( - y => y.Open() == Mock.Of())); - } } } diff --git a/tests/Avalonia.UnitTests/TextTestHelper.cs b/tests/Avalonia.UnitTests/TextTestHelper.cs deleted file mode 100644 index b572333027..0000000000 --- a/tests/Avalonia.UnitTests/TextTestHelper.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace Avalonia.UnitTests -{ - public static class TextTestHelper - { - public static int GetStartCharIndex(ReadOnlyMemory text) - { - if (!MemoryMarshal.TryGetString(text, out _, out var start, out _)) - throw new InvalidOperationException("text memory should have been a string"); - return start; - } - } -} From 99f4739e85c9bb29ed782593d8298d42bbb41582 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 27 Apr 2023 23:38:46 -0400 Subject: [PATCH 15/40] Fix scenario when application attribute is not used --- src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index 12f8515209..f98240d6d7 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -128,7 +128,8 @@ public sealed class HeadlessUnitTestSession : IDisposable if (s_session is not null) { - if (appBuilderEntryPointType != s_session.EntryPointType) + var hasNoAttribute = appBuilderEntryPointType == null && s_session.EntryPointType == typeof(Application); + if (!hasNoAttribute && appBuilderEntryPointType != s_session.EntryPointType) { // Avalonia doesn't support multiple Application instances. At least at the moment. throw new System.InvalidOperationException( From bb893b189c3e46b779d7d206eee156d0b584f29a Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 28 Apr 2023 04:58:54 -0400 Subject: [PATCH 16/40] Make HeadlessUnitTestSession run individual frames per test --- .../AvaloniaTestMethodCommand.cs | 9 +- .../Avalonia.Headless.NUnit/ExecutionQueue.cs | 71 -------------- .../Avalonia.Headless.XUnit.csproj | 6 -- ...unner.cs => AvaloniaTestAssemblyRunner.cs} | 0 .../AvaloniaTestCase.cs | 12 +-- .../AvaloniaTestCaseRunner.cs | 98 +++++++++++++++++++ .../AvaloniaTheoryTestCase.cs | 8 +- .../HeadlessUnitTestSession.cs | 95 +++++++++++++++--- .../ThreadingTests.cs | 7 ++ 9 files changed, 200 insertions(+), 106 deletions(-) delete mode 100644 src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs rename src/Headless/Avalonia.Headless.XUnit/{AvaloniaTestRunner.cs => AvaloniaTestAssemblyRunner.cs} (100%) create mode 100644 src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCaseRunner.cs diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs index a7e461f76e..318005f0b0 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs @@ -1,6 +1,7 @@ using System; using System.Reflection; using System.Threading.Tasks; +using Avalonia.Threading; using NUnit.Framework.Interfaces; using NUnit.Framework.Internal; using NUnit.Framework.Internal.Commands; @@ -30,12 +31,12 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand { if (s_beforeTest.GetValue(beforeAndAfterTestCommand) is Action beforeTest) { - Action beforeAction = c => session.Dispatcher.Invoke(() => beforeTest(c)); + Action beforeAction = c => session.Dispatch(() => beforeTest(c), default); s_beforeTest.SetValue(beforeAndAfterTestCommand, beforeAction); } if (s_afterTest.GetValue(beforeAndAfterTestCommand) is Action afterTest) { - Action afterAction = c => session.Dispatcher.Invoke(() => afterTest(c)); + Action afterAction = c => session.Dispatch(() => afterTest(c), default); s_afterTest.SetValue(beforeAndAfterTestCommand, afterAction); } } @@ -52,10 +53,10 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand return command; } - + public override TestResult Execute(TestExecutionContext context) { - return _session.Dispatcher.InvokeOnQueue(() => ExecuteTestMethod(context)); + return _session.Dispatch(() => ExecuteTestMethod(context), default).GetAwaiter().GetResult(); } // Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work. diff --git a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs deleted file mode 100644 index b42f126389..0000000000 --- a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Avalonia.Threading; - -namespace Avalonia.Headless.NUnit; - -// To force all tests (which might run in parallel) to be executed in a queue. -internal static class ExecutionQueue -{ - private static bool s_running; - private static Queue> s_queue = new(); - - private static async void TryExecuteNext() - { - if (s_running || s_queue.Count == 0) return; - try - { - s_running = true; - await s_queue.Dequeue()(); - } - finally - { - s_running = false; - } - TryExecuteNext(); - } - - private static void PostToTheQueue(this Dispatcher dispatcher, Func cb) - { - dispatcher.Post(() => - { - s_queue.Enqueue(cb); - TryExecuteNext(); - }); - } - - internal static Task ExecuteOnQueue(this Dispatcher dispatcher, Func> cb) - { - var tcs = new TaskCompletionSource(); - PostToTheQueue(dispatcher, async () => - { - try - { - var result = await cb(); - tcs.TrySetResult(result); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }); - return tcs.Task; - } - - public static TResult InvokeOnQueue(this Dispatcher dispatcher, Func> cb, CancellationToken cancellationToken = default) - { - return dispatcher - .InvokeAsync(() => ExecuteOnQueue(dispatcher, cb), DispatcherPriority.Normal, cancellationToken) - .GetTask().Unwrap() - .GetAwaiter().GetResult(); - } - - public static Task InvokeOnQueueAsync(this Dispatcher dispatcher, Func> cb, CancellationToken cancellationToken = default) - { - return dispatcher - .InvokeAsync(() => ExecuteOnQueue(dispatcher, cb), DispatcherPriority.Normal, cancellationToken) - .GetTask().Unwrap(); - } -} diff --git a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj index c244a1a4cd..4ab70eb07d 100644 --- a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj +++ b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj @@ -13,12 +13,6 @@ - - - ExecutionQueue.cs - - - diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestAssemblyRunner.cs similarity index 100% rename from src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs rename to src/Headless/Avalonia.Headless.XUnit/AvaloniaTestAssemblyRunner.cs diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs index b6c0cd11c4..092662745c 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs @@ -1,8 +1,8 @@ using System; using System.ComponentModel; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; -using Avalonia.Headless.NUnit; using Avalonia.Threading; using Xunit.Abstractions; using Xunit.Sdk; @@ -37,12 +37,10 @@ internal class AvaloniaTestCase : XunitTestCase // We need to block the XUnit thread to ensure its concurrency throttle is effective. // See https://github.com/AArnott/Xunit.StaFact/pull/55#issuecomment-826187354 for details. - var runSummary = session.Dispatcher.InvokeOnQueue(async () => - { - var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, - TestMethodArguments, messageBus, aggregator, cancellationTokenSource); - return await runner.RunAsync(); - }, cancellationTokenSource.Token); + var runSummary = AvaloniaTestCaseRunner + .RunTest(session, this, DisplayName, SkipReason, constructorArguments, + TestMethodArguments, messageBus, aggregator, cancellationTokenSource) + .GetAwaiter().GetResult(); return Task.FromResult(runSummary); } diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCaseRunner.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCaseRunner.cs new file mode 100644 index 0000000000..97fcfa2521 --- /dev/null +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCaseRunner.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Avalonia.Headless.XUnit; + +internal class AvaloniaTestCaseRunner : XunitTestCaseRunner +{ + private readonly Action? _onAfterTestInvoked; + + public AvaloniaTestCaseRunner( + Action? onAfterTestInvoked, + IXunitTestCase testCase, string displayName, string skipReason, object[] constructorArguments, + object[] testMethodArguments, IMessageBus messageBus, ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) : base(testCase, displayName, skipReason, constructorArguments, + testMethodArguments, messageBus, aggregator, cancellationTokenSource) + { + _onAfterTestInvoked = onAfterTestInvoked; + } + + public static Task RunTest(HeadlessUnitTestSession session, + IXunitTestCase testCase, string displayName, string skipReason, object[] constructorArguments, + object[] testMethodArguments, IMessageBus messageBus, ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + { + var afterTest = () => Dispatcher.UIThread.RunJobs(); + return session.Dispatch(async () => + { + var runner = new AvaloniaTestCaseRunner(afterTest, testCase, displayName, + skipReason, constructorArguments, testMethodArguments, messageBus, aggregator, cancellationTokenSource); + return await runner.RunAsync(); + }, cancellationTokenSource.Token); + } + + protected override XunitTestRunner CreateTestRunner(ITest test, IMessageBus messageBus, Type testClass, + object[] constructorArguments, + MethodInfo testMethod, object[] testMethodArguments, string skipReason, + IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) + { + return new AvaloniaTestRunner(_onAfterTestInvoked, test, messageBus, testClass, constructorArguments, + testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource); + } + + private class AvaloniaTestRunner : XunitTestRunner + { + private readonly Action? _onAfterTestInvoked; + + public AvaloniaTestRunner( + Action? onAfterTestInvoked, + ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, + object[] testMethodArguments, string skipReason, + IReadOnlyList beforeAfterAttributes, ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) : base(test, messageBus, testClass, constructorArguments, + testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource) + { + _onAfterTestInvoked = onAfterTestInvoked; + } + + protected override Task InvokeTestMethodAsync(ExceptionAggregator aggregator) + { + return new AvaloniaTestInvoker(_onAfterTestInvoked, Test, MessageBus, TestClass, ConstructorArguments, + TestMethod, TestMethodArguments, BeforeAfterAttributes, aggregator, CancellationTokenSource).RunAsync(); + } + } + + private class AvaloniaTestInvoker : XunitTestInvoker + { + private readonly Action? _onAfterTestInvoked; + + public AvaloniaTestInvoker( + Action? onAfterTestInvoked, + ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, + object[] testMethodArguments, IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) : base(test, messageBus, + testClass, constructorArguments, testMethod, testMethodArguments, beforeAfterAttributes, aggregator, + cancellationTokenSource) + { + _onAfterTestInvoked = onAfterTestInvoked; + } + + protected override async Task AfterTestMethodInvokedAsync() + { + await base.AfterTestMethodInvokedAsync(); + + // Only here we can execute random code after the test, where exception will be properly handled by the XUnit. + if (_onAfterTestInvoked is not null) + { + Aggregator.Run(_onAfterTestInvoked); + } + } + } +} diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs index 4b56de6e6c..ea7e7abee4 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs @@ -2,7 +2,6 @@ using System.ComponentModel; using System.Threading; using System.Threading.Tasks; -using Avalonia.Headless.NUnit; using Xunit.Abstractions; using Xunit.Sdk; @@ -25,11 +24,8 @@ internal class AvaloniaTheoryTestCase : XunitTheoryTestCase { var session = HeadlessUnitTestSession.GetOrStartForAssembly(Method.ToRuntimeMethod().DeclaringType?.Assembly); - return session.Dispatcher.InvokeOnQueueAsync(async () => - { - var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, + return AvaloniaTestCaseRunner + .RunTest(session, this, DisplayName, SkipReason, constructorArguments, TestMethodArguments, messageBus, aggregator, cancellationTokenSource); - return await runner.RunAsync(); - }, cancellationTokenSource.Token); } } diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index f98240d6d7..81295f7fc3 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; @@ -18,9 +20,11 @@ namespace Avalonia.Headless; /// public sealed class HeadlessUnitTestSession : IDisposable { - private readonly CancellationTokenSource _cancellationToken; + private readonly CancellationTokenSource _cancellationTokenSource; private static HeadlessUnitTestSession? s_session; private static object s_lock = new(); + private readonly BlockingCollection _queue; + private readonly Task _dispatchTask; internal const DynamicallyAccessedMemberTypes DynamicallyAccessed = DynamicallyAccessedMemberTypes.PublicMethods | @@ -29,9 +33,11 @@ public sealed class HeadlessUnitTestSession : IDisposable private HeadlessUnitTestSession(Type entryPointType, Application application, SynchronizationContext synchronizationContext, - Dispatcher dispatcher, CancellationTokenSource cancellationToken) + Dispatcher dispatcher, CancellationTokenSource cancellationTokenSource, BlockingCollection queue, Task _dispatchTask) { - _cancellationToken = cancellationToken; + _cancellationTokenSource = cancellationTokenSource; + _queue = queue; + this._dispatchTask = _dispatchTask; EntryPointType = entryPointType; Dispatcher = dispatcher; Application = application; @@ -43,9 +49,63 @@ public sealed class HeadlessUnitTestSession : IDisposable public Dispatcher Dispatcher { get; } internal Type EntryPointType { get; } + public Task Dispatch(Action action, CancellationToken cancellationToken) + { + return Dispatch(() => { action(); return Task.FromResult(0); }, cancellationToken); + } + + public Task Dispatch(Func action, CancellationToken cancellationToken) + { + return Dispatch(() => Task.FromResult(action()), cancellationToken); + } + + public Task Dispatch(Func> action, CancellationToken cancellationToken) + { + if (_cancellationTokenSource.IsCancellationRequested) + { + throw new ObjectDisposedException("Session was already disposed."); + } + + var token = _cancellationTokenSource.Token; + + var tcs = new TaskCompletionSource(); + _queue.Add(() => + { + var cts = new CancellationTokenSource(); + using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts); + using var localCts = cancellationToken.Register(s => ((CancellationTokenSource)s!).Cancel(), cts); + + try + { + var task = action(); + task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts); + + if (cts.IsCancellationRequested) + { + return; + } + + var frame = new DispatcherFrame(); + using var innerCts = cts.Token.Register(() => frame.Continue = false); + Dispatcher.PushFrame(frame); + + var result = task.GetAwaiter().GetResult(); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + return tcs.Task; + } + public void Dispose() { - _cancellationToken.Cancel(); + _cancellationTokenSource.Cancel(); + _queue.CompleteAdding(); + _dispatchTask.Wait(); + _cancellationTokenSource.Dispose(); } /// @@ -73,9 +133,10 @@ public sealed class HeadlessUnitTestSession : IDisposable { var tcs = new TaskCompletionSource(); var cancellationTokenSource = new CancellationTokenSource(); + var queue = new BlockingCollection(); - Thread? thread = null; - thread = new Thread(() => + Task? task = null; + task = Task.Run(() => { try { @@ -93,10 +154,10 @@ public sealed class HeadlessUnitTestSession : IDisposable { throw new InvalidOperationException("Avalonia Headless platform has failed to initialize."); } - + // ReSharper disable once AccessToModifiedClosure tcs.SetResult(new HeadlessUnitTestSession(entryPointType, Application.Current!, - SynchronizationContext.Current!, Dispatcher.UIThread, cancellationTokenSource)); + SynchronizationContext.Current!, Dispatcher.UIThread, cancellationTokenSource, queue, task!)); } catch (Exception e) { @@ -104,13 +165,23 @@ public sealed class HeadlessUnitTestSession : IDisposable return; } - Dispatcher.UIThread.MainLoop(cancellationTokenSource.Token); - }) { IsBackground = true }; - thread.Start(); + while (!cancellationTokenSource.IsCancellationRequested) + { + try + { + var action = queue.Take(cancellationTokenSource.Token); + action(); + } + catch (OperationCanceledException) + { + + } + } + }); return tcs.Task.GetAwaiter().GetResult(); } - + /// /// Creates a session from AvaloniaTestApplicationAttribute attribute or reuses any existing. /// If AvaloniaTestApplicationAttribute doesn't exist, empty application is used. diff --git a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs index e26b902eb1..b6ba7de8c5 100644 --- a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs +++ b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs @@ -13,6 +13,13 @@ public class ThreadingTests Dispatcher.UIThread.VerifyAccess(); } + // This test should always fail, uncomment to test if it fails. + // [AvaloniaFact] + // public void Should_Fail_Test_On_Delayed_Post_When_FlushDispatcher() + // { + // Dispatcher.UIThread.Post(() => throw new InvalidOperationException(), DispatcherPriority.Default); + // } + [AvaloniaTheory] [InlineData(1)] [InlineData(10)] From 73d726a9f565642151b1602b567a81004260483c Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 28 Apr 2023 04:59:07 -0400 Subject: [PATCH 17/40] Make AvaloniaTestAssemblyRunner use existing test case runners --- .../AvaloniaTestAssemblyRunner.cs | 100 +++++++++++++++++- .../AvaloniaTestFramework.cs | 2 +- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestAssemblyRunner.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestAssemblyRunner.cs index 86efe33f5c..4b1cf84914 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestAssemblyRunner.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestAssemblyRunner.cs @@ -9,11 +9,11 @@ using Xunit.Sdk; namespace Avalonia.Headless.XUnit; -internal class AvaloniaTestRunner : XunitTestAssemblyRunner +internal class AvaloniaTestAssemblyRunner : XunitTestAssemblyRunner { private HeadlessUnitTestSession? _session; - public AvaloniaTestRunner(ITestAssembly testAssembly, IEnumerable testCases, + public AvaloniaTestAssemblyRunner(ITestAssembly testAssembly, IEnumerable testCases, IMessageSink diagnosticMessageSink, IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) : base(testAssembly, testCases, diagnosticMessageSink, executionMessageSink, executionOptions) @@ -22,8 +22,9 @@ internal class AvaloniaTestRunner : XunitTestAssemblyRunner protected override void SetupSyncContext(int maxParallelThreads) { - _session = HeadlessUnitTestSession.GetOrStartForAssembly(Assembly.Load(new AssemblyName(TestAssembly.Assembly.Name))); - SynchronizationContext.SetSynchronizationContext(_session.SynchronizationContext); + _session = HeadlessUnitTestSession.GetOrStartForAssembly( + Assembly.Load(new AssemblyName(TestAssembly.Assembly.Name))); + base.SetupSyncContext(1); } public override void Dispose() @@ -31,4 +32,95 @@ internal class AvaloniaTestRunner : XunitTestAssemblyRunner _session?.Dispose(); base.Dispose(); } + + protected override Task RunTestCollectionAsync( + IMessageBus messageBus, + ITestCollection testCollection, + IEnumerable testCases, + CancellationTokenSource cancellationTokenSource) + { + return new AvaloniaTestCollectionRunner(_session!, testCollection, testCases, DiagnosticMessageSink, messageBus, + TestCaseOrderer, new ExceptionAggregator(Aggregator), cancellationTokenSource).RunAsync(); + } + + private class AvaloniaTestCollectionRunner : XunitTestCollectionRunner + { + private readonly HeadlessUnitTestSession _session; + + public AvaloniaTestCollectionRunner(HeadlessUnitTestSession session, + ITestCollection testCollection, IEnumerable testCases, + IMessageSink diagnosticMessageSink, IMessageBus messageBus, ITestCaseOrderer testCaseOrderer, + ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) : base(testCollection, + testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, cancellationTokenSource) + { + _session = session; + } + + protected override Task RunTestClassAsync( + ITestClass testClass, + IReflectionTypeInfo @class, + IEnumerable testCases) + { + return new AvaloniaTestClassRunner(_session, testClass, @class, testCases, DiagnosticMessageSink, MessageBus, + TestCaseOrderer, new ExceptionAggregator(Aggregator), CancellationTokenSource, + CollectionFixtureMappings).RunAsync(); + } + } + + private class AvaloniaTestClassRunner : XunitTestClassRunner + { + private readonly HeadlessUnitTestSession _session; + + public AvaloniaTestClassRunner(HeadlessUnitTestSession session, ITestClass testClass, + IReflectionTypeInfo @class, + IEnumerable testCases, IMessageSink diagnosticMessageSink, IMessageBus messageBus, + ITestCaseOrderer testCaseOrderer, ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource, IDictionary collectionFixtureMappings) : + base(testClass, @class, testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, + cancellationTokenSource, collectionFixtureMappings) + { + _session = session; + } + + protected override Task RunTestMethodAsync( + ITestMethod testMethod, + IReflectionMethodInfo method, + IEnumerable testCases, + object[] constructorArguments) + { + return new AvaloniaTestMethodRunner(_session, testMethod, Class, method, testCases, DiagnosticMessageSink, + MessageBus, new ExceptionAggregator(Aggregator), CancellationTokenSource, + constructorArguments).RunAsync(); + } + } + + private class AvaloniaTestMethodRunner : XunitTestMethodRunner + { + private readonly HeadlessUnitTestSession _session; + private readonly IMessageBus _messageBus; + private readonly ExceptionAggregator _aggregator; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly object[] _constructorArguments; + + public AvaloniaTestMethodRunner(HeadlessUnitTestSession session, ITestMethod testMethod, + IReflectionTypeInfo @class, + IReflectionMethodInfo method, IEnumerable testCases, IMessageSink diagnosticMessageSink, + IMessageBus messageBus, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource, + object[] constructorArguments) : base(testMethod, @class, method, testCases, diagnosticMessageSink, + messageBus, aggregator, cancellationTokenSource, constructorArguments) + { + _session = session; + _messageBus = messageBus; + _aggregator = aggregator; + _cancellationTokenSource = cancellationTokenSource; + _constructorArguments = constructorArguments; + } + + protected override Task RunTestCaseAsync(IXunitTestCase testCase) + { + return AvaloniaTestCaseRunner.RunTest(_session, testCase, testCase.DisplayName, testCase.SkipReason, + _constructorArguments, testCase.TestMethodArguments, _messageBus, _aggregator, + _cancellationTokenSource); + } + } } diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs index 2d45228c57..aa9b3e7e18 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs @@ -27,7 +27,7 @@ internal class AvaloniaTestFramework : XunitTestFramework IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) { - using (var assemblyRunner = new AvaloniaTestRunner( + using (var assemblyRunner = new AvaloniaTestAssemblyRunner( TestAssembly, testCases, DiagnosticMessageSink, executionMessageSink, executionOptions)) await assemblyRunner.RunAsync(); } From 0547001f3b16be9a0386c923e9f209fd4c30340c Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 28 Apr 2023 05:59:07 -0400 Subject: [PATCH 18/40] Cleanup --- .../HeadlessUnitTestSession.cs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index 81295f7fc3..312064064f 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -31,22 +31,14 @@ public sealed class HeadlessUnitTestSession : IDisposable DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; - private HeadlessUnitTestSession(Type entryPointType, Application application, - SynchronizationContext synchronizationContext, - Dispatcher dispatcher, CancellationTokenSource cancellationTokenSource, BlockingCollection queue, Task _dispatchTask) + private HeadlessUnitTestSession(Type entryPointType, CancellationTokenSource cancellationTokenSource, BlockingCollection queue, Task _dispatchTask) { _cancellationTokenSource = cancellationTokenSource; _queue = queue; this._dispatchTask = _dispatchTask; EntryPointType = entryPointType; - Dispatcher = dispatcher; - Application = application; - SynchronizationContext = synchronizationContext; } - - public Application Application { get; } - public SynchronizationContext SynchronizationContext { get; } - public Dispatcher Dispatcher { get; } + internal Type EntryPointType { get; } public Task Dispatch(Action action, CancellationToken cancellationToken) @@ -87,7 +79,7 @@ public sealed class HeadlessUnitTestSession : IDisposable var frame = new DispatcherFrame(); using var innerCts = cts.Token.Register(() => frame.Continue = false); - Dispatcher.PushFrame(frame); + Dispatcher.UIThread.PushFrame(frame); var result = task.GetAwaiter().GetResult(); tcs.TrySetResult(result); @@ -156,8 +148,7 @@ public sealed class HeadlessUnitTestSession : IDisposable } // ReSharper disable once AccessToModifiedClosure - tcs.SetResult(new HeadlessUnitTestSession(entryPointType, Application.Current!, - SynchronizationContext.Current!, Dispatcher.UIThread, cancellationTokenSource, queue, task!)); + tcs.SetResult(new HeadlessUnitTestSession(entryPointType, cancellationTokenSource, queue, task!)); } catch (Exception e) { From 35782d9a1b3cb13ecfa9a7ddc6bdb7ca7703e52e Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 28 Apr 2023 06:25:10 -0400 Subject: [PATCH 19/40] Make sure callback are on the UI thread --- .../Avalonia.Headless/HeadlessUnitTestSession.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index 312064064f..b44d530442 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -64,13 +64,14 @@ public sealed class HeadlessUnitTestSession : IDisposable _queue.Add(() => { var cts = new CancellationTokenSource(); - using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts); - using var localCts = cancellationToken.Register(s => ((CancellationTokenSource)s!).Cancel(), cts); + using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); + using var localCts = cancellationToken.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); try { var task = action(); - task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts); + task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts, + TaskScheduler.FromCurrentSynchronizationContext()); if (cts.IsCancellationRequested) { @@ -78,7 +79,7 @@ public sealed class HeadlessUnitTestSession : IDisposable } var frame = new DispatcherFrame(); - using var innerCts = cts.Token.Register(() => frame.Continue = false); + using var innerCts = cts.Token.Register(() => frame.Continue = false, true); Dispatcher.UIThread.PushFrame(frame); var result = task.GetAwaiter().GetResult(); From 5a742ea2c4f1702a180bcfc56da93c96d3cd5258 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 28 Apr 2023 06:25:20 -0400 Subject: [PATCH 20/40] Run jobs after headless input --- .../HeadlessWindowExtensions.cs | 19 ++++++++++--------- .../Avalonia.Headless.UnitTests/InputTests.cs | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs index 8fbc5ec6ef..a3bda25b19 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs @@ -44,53 +44,54 @@ public static class HeadlessWindowExtensions /// Simulates keyboard press on the headless window/toplevel. /// public static void KeyPress(this TopLevel topLevel, Key key, RawInputModifiers modifiers) => - RunJobsAndGetImpl(topLevel).KeyPress(key, modifiers); + RunJobsOnImpl(topLevel, w => w.KeyPress(key, modifiers)); /// /// Simulates keyboard release on the headless window/toplevel. /// public static void KeyRelease(this TopLevel topLevel, Key key, RawInputModifiers modifiers) => - RunJobsAndGetImpl(topLevel).KeyRelease(key, modifiers); + RunJobsOnImpl(topLevel, w => w.KeyRelease(key, modifiers)); /// /// Simulates mouse down on the headless window/toplevel. /// public static void MouseDown(this TopLevel topLevel, Point point, MouseButton button, RawInputModifiers modifiers = RawInputModifiers.None) => - RunJobsAndGetImpl(topLevel).MouseDown(point, button, modifiers); + RunJobsOnImpl(topLevel, w => w.MouseDown(point, button, modifiers)); /// /// Simulates mouse move on the headless window/toplevel. /// public static void MouseMove(this TopLevel topLevel, Point point, RawInputModifiers modifiers = RawInputModifiers.None) => - RunJobsAndGetImpl(topLevel).MouseMove(point, modifiers); + RunJobsOnImpl(topLevel, w => w.MouseMove(point, modifiers)); /// /// Simulates mouse up on the headless window/toplevel. /// public static void MouseUp(this TopLevel topLevel, Point point, MouseButton button, RawInputModifiers modifiers = RawInputModifiers.None) => - RunJobsAndGetImpl(topLevel).MouseUp(point, button, modifiers); + RunJobsOnImpl(topLevel, w => w.MouseUp(point, button, modifiers)); /// /// Simulates mouse wheel on the headless window/toplevel. /// public static void MouseWheel(this TopLevel topLevel, Point point, Vector delta, RawInputModifiers modifiers = RawInputModifiers.None) => - RunJobsAndGetImpl(topLevel).MouseWheel(point, delta, modifiers); + RunJobsOnImpl(topLevel, w => w.MouseWheel(point, delta, modifiers)); /// /// Simulates drag'n'drop target on the headless window/toplevel. /// public static void DragDrop(this TopLevel topLevel, Point point, RawDragEventType type, IDataObject data, DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None) => - RunJobsAndGetImpl(topLevel).DragDrop(point, type, data, effects, modifiers); + RunJobsOnImpl(topLevel, w => w.DragDrop(point, type, data, effects, modifiers)); - private static IHeadlessWindow RunJobsAndGetImpl(this TopLevel topLevel) + private static void RunJobsOnImpl(this TopLevel topLevel, Action action) { Dispatcher.UIThread.RunJobs(); - return GetImpl(topLevel); + action(GetImpl(topLevel)); + Dispatcher.UIThread.RunJobs(); } private static IHeadlessWindow GetImpl(this TopLevel topLevel) diff --git a/tests/Avalonia.Headless.UnitTests/InputTests.cs b/tests/Avalonia.Headless.UnitTests/InputTests.cs index 5a7d3faae9..5e3b6e762f 100644 --- a/tests/Avalonia.Headless.UnitTests/InputTests.cs +++ b/tests/Avalonia.Headless.UnitTests/InputTests.cs @@ -46,7 +46,7 @@ public class InputTests _window.MouseDown(new Point(50, 50), MouseButton.Left); _window.MouseUp(new Point(50, 50), MouseButton.Left); - + Assert.True(buttonClicked); } From a3df4ad3b4d05f6bf42437d85eaac506c64f1052 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Tue, 2 May 2023 20:06:01 -0400 Subject: [PATCH 21/40] Reset application after each test --- src/Avalonia.Controls/AppBuilder.cs | 21 ++- .../AvaloniaTestMethodCommand.cs | 31 ++++- .../HeadlessUnitTestSession.cs | 126 +++++++++--------- 3 files changed, 105 insertions(+), 73 deletions(-) diff --git a/src/Avalonia.Controls/AppBuilder.cs b/src/Avalonia.Controls/AppBuilder.cs index 9af50180dd..77cc9d4dcb 100644 --- a/src/Avalonia.Controls/AppBuilder.cs +++ b/src/Avalonia.Controls/AppBuilder.cs @@ -288,17 +288,26 @@ namespace Avalonia } s_setupWasAlreadyCalled = true; + SetupUnsafe(); + } + + /// + /// Setup method that doesn't check for input initalizers being set. + /// Nor + /// + internal void SetupUnsafe() + { _optionsInitializers?.Invoke(); - RuntimePlatformServicesInitializer(); - RenderingSubsystemInitializer(); - WindowingSubsystemInitializer(); - AfterPlatformServicesSetupCallback(Self); - Instance = _appFactory(); + RuntimePlatformServicesInitializer?.Invoke(); + RenderingSubsystemInitializer?.Invoke(); + WindowingSubsystemInitializer?.Invoke(); + AfterPlatformServicesSetupCallback?.Invoke(Self); + Instance = _appFactory!(); Instance.ApplicationLifetime = _lifetime; AvaloniaLocator.CurrentMutable.BindToSelf(Instance); Instance.RegisterServices(); Instance.Initialize(); - AfterSetupCallback(Self); + AfterSetupCallback?.Invoke(Self); Instance.OnFrameworkInitializationCompleted(); } } diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs index 318005f0b0..91428388b6 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; using Avalonia.Threading; @@ -11,6 +12,8 @@ namespace Avalonia.Headless.NUnit; internal class AvaloniaTestMethodCommand : DelegatingTestCommand { private readonly HeadlessUnitTestSession _session; + private readonly List _beforeTest; + private readonly List _afterTest; private static FieldInfo s_innerCommand = typeof(DelegatingTestCommand) .GetField("innerCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; @@ -19,24 +22,35 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand) .GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!; - private AvaloniaTestMethodCommand(HeadlessUnitTestSession session, TestCommand innerCommand) + private AvaloniaTestMethodCommand( + HeadlessUnitTestSession session, + TestCommand innerCommand, + List beforeTest, + List afterTest) : base(innerCommand) { _session = session; + _beforeTest = beforeTest; + _afterTest = afterTest; } public static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command) + { + return ProcessCommand(session, command, new List(), new List()); + } + + private static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command, List before, List after) { if (command is BeforeAndAfterTestCommand beforeAndAfterTestCommand) { if (s_beforeTest.GetValue(beforeAndAfterTestCommand) is Action beforeTest) { - Action beforeAction = c => session.Dispatch(() => beforeTest(c), default); + Action beforeAction = c => before.Add(() => beforeTest(c)); s_beforeTest.SetValue(beforeAndAfterTestCommand, beforeAction); } if (s_afterTest.GetValue(beforeAndAfterTestCommand) is Action afterTest) { - Action afterAction = c => session.Dispatch(() => afterTest(c), default); + Action afterAction = c => after.Add(() => afterTest(c)); s_afterTest.SetValue(beforeAndAfterTestCommand, afterAction); } } @@ -44,11 +58,11 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand if (command is DelegatingTestCommand delegatingTestCommand && s_innerCommand.GetValue(delegatingTestCommand) is TestCommand inner) { - s_innerCommand.SetValue(delegatingTestCommand, ProcessCommand(session, inner)); + s_innerCommand.SetValue(delegatingTestCommand, ProcessCommand(session, inner, before, after)); } else if (command is TestMethodCommand methodCommand) { - return new AvaloniaTestMethodCommand(session, methodCommand); + return new AvaloniaTestMethodCommand(session, methodCommand, before, after); } return command; @@ -62,6 +76,8 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand // Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work. private async Task ExecuteTestMethod(TestExecutionContext context) { + _beforeTest.ForEach(a => a()); + var testMethod = innerCommand.Test.Method; var methodInfo = testMethod!.MethodInfo; @@ -81,6 +97,11 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand if (context.CurrentResult.AssertionResults.Count > 0) context.CurrentResult.RecordTestCompletion(); + if (context.ExecutionStatus != TestExecutionStatus.AbortRequested) + { + _afterTest.ForEach(a => a()); + } + return context.CurrentResult; } } diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index b44d530442..eb47201400 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; +using Avalonia.Controls.Platform; +using Avalonia.Reactive; +using Avalonia.Rendering; using Avalonia.Threading; namespace Avalonia.Headless; @@ -15,14 +19,12 @@ namespace Avalonia.Headless; /// to keep execution flow on the UI thread. /// Disposing unit test session stops internal dispatcher loop. /// -/// -/// As Avalonia supports only a single Application instance created, this session must be created only once as well. -/// public sealed class HeadlessUnitTestSession : IDisposable { + private static readonly ConcurrentDictionary s_session = new(); + + private readonly AppBuilder _appBuilder; private readonly CancellationTokenSource _cancellationTokenSource; - private static HeadlessUnitTestSession? s_session; - private static object s_lock = new(); private readonly BlockingCollection _queue; private readonly Task _dispatchTask; @@ -30,27 +32,41 @@ public sealed class HeadlessUnitTestSession : IDisposable DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; - - private HeadlessUnitTestSession(Type entryPointType, CancellationTokenSource cancellationTokenSource, BlockingCollection queue, Task _dispatchTask) + + private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource, + BlockingCollection queue, Task dispatchTask) { + _appBuilder = appBuilder; _cancellationTokenSource = cancellationTokenSource; _queue = queue; - this._dispatchTask = _dispatchTask; - EntryPointType = entryPointType; + _dispatchTask = dispatchTask; } - internal Type EntryPointType { get; } - + /// public Task Dispatch(Action action, CancellationToken cancellationToken) { - return Dispatch(() => { action(); return Task.FromResult(0); }, cancellationToken); + return Dispatch(() => + { + action(); + return Task.FromResult(0); + }, cancellationToken); } - + + /// public Task Dispatch(Func action, CancellationToken cancellationToken) { return Dispatch(() => Task.FromResult(action()), cancellationToken); } + /// + /// Dispatch method queues an async operation on the dispatcher thread, creates a new application instance, + /// setting app avalonia services, and runs parameter. + /// + /// Action to execute on the dispatcher thread with avalonia services. + /// Cancellation token to cancel execution. + /// + /// If global session was already cancelled and thread killed, it's not possible to dispatch any actions again + /// public Task Dispatch(Func> action, CancellationToken cancellationToken) { if (_cancellationTokenSource.IsCancellationRequested) @@ -63,6 +79,8 @@ public sealed class HeadlessUnitTestSession : IDisposable var tcs = new TaskCompletionSource(); _queue.Add(() => { + using var application = EnsureApplication(); + var cts = new CancellationTokenSource(); using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); using var localCts = cancellationToken.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); @@ -92,7 +110,28 @@ public sealed class HeadlessUnitTestSession : IDisposable }); return tcs.Task; } - + + private IDisposable EnsureApplication() + { + var scope = AvaloniaLocator.EnterScope(); + try + { + Dispatcher.ResetForUnitTests(); + _appBuilder.SetupUnsafe(); + } + catch + { + scope.Dispose(); + throw; + } + + return Disposable.Create(() => + { + scope.Dispose(); + Dispatcher.ResetForUnitTests(); + }); + } + public void Dispose() { _cancellationTokenSource.Cancel(); @@ -101,19 +140,6 @@ public sealed class HeadlessUnitTestSession : IDisposable _cancellationTokenSource.Dispose(); } - /// - /// Creates instance of . - /// - /// - /// Parameter from which should be created. - /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. - /// - public static HeadlessUnitTestSession StartNew< - [DynamicallyAccessedMembers(DynamicallyAccessed)] TEntryPointType>() - { - return StartNew(typeof(TEntryPointType)); - } - /// /// Creates instance of . /// @@ -122,7 +148,8 @@ public sealed class HeadlessUnitTestSession : IDisposable /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. /// public static HeadlessUnitTestSession StartNew( - [DynamicallyAccessedMembers(DynamicallyAccessed)] Type entryPointType) + [DynamicallyAccessedMembers(DynamicallyAccessed)] + Type entryPointType) { var tcs = new TaskCompletionSource(); var cancellationTokenSource = new CancellationTokenSource(); @@ -141,15 +168,8 @@ public sealed class HeadlessUnitTestSession : IDisposable appBuilder = appBuilder.UseHeadless(new AvaloniaHeadlessPlatformOptions()); } - appBuilder.SetupWithoutStarting(); - - if (!Dispatcher.UIThread.SupportsRunLoops) - { - throw new InvalidOperationException("Avalonia Headless platform has failed to initialize."); - } - // ReSharper disable once AccessToModifiedClosure - tcs.SetResult(new HeadlessUnitTestSession(entryPointType, cancellationTokenSource, queue, task!)); + tcs.SetResult(new HeadlessUnitTestSession(appBuilder, cancellationTokenSource, queue, task!)); } catch (Exception e) { @@ -166,7 +186,6 @@ public sealed class HeadlessUnitTestSession : IDisposable } catch (OperationCanceledException) { - } } }); @@ -178,34 +197,17 @@ public sealed class HeadlessUnitTestSession : IDisposable /// Creates a session from AvaloniaTestApplicationAttribute attribute or reuses any existing. /// If AvaloniaTestApplicationAttribute doesn't exist, empty application is used. /// - /// - /// Note, only single session can be crated per app execution. - /// - [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "AvaloniaTestApplicationAttribute attribute should preserve type information.")] + [UnconditionalSuppressMessage("Trimming", "IL2072", + Justification = "AvaloniaTestApplicationAttribute attribute should preserve type information.")] public static HeadlessUnitTestSession GetOrStartForAssembly(Assembly? assembly) { - lock (s_lock) + return s_session.GetOrAdd(assembly ?? typeof(HeadlessUnitTestSession).Assembly, a => { - var appBuilderEntryPointType = assembly?.GetCustomAttribute() + var appBuilderEntryPointType = a.GetCustomAttribute() ?.AppBuilderEntryPointType; - - if (s_session is not null) - { - var hasNoAttribute = appBuilderEntryPointType == null && s_session.EntryPointType == typeof(Application); - if (!hasNoAttribute && appBuilderEntryPointType != s_session.EntryPointType) - { - // Avalonia doesn't support multiple Application instances. At least at the moment. - throw new System.InvalidOperationException( - "AvaloniaTestApplicationAttribute must be defined only once per single unit tests session."); - } - - return s_session; - } - - - s_session = appBuilderEntryPointType is not null ? StartNew(appBuilderEntryPointType) : StartNew(typeof(Application)); - - return s_session; - } + return appBuilderEntryPointType is not null ? + StartNew(appBuilderEntryPointType) : + StartNew(typeof(Application)); + }); } } From cdadf9be8820d17b9143c6ea791c7c354c2060e5 Mon Sep 17 00:00:00 2001 From: affederaffe <68356204+affederaffe@users.noreply.github.com> Date: Fri, 5 May 2023 11:20:34 +0200 Subject: [PATCH 22/40] Bump Tmds.DBus.SourceGenerator --- src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj | 2 +- src/Avalonia.FreeDesktop/DBusSystemDialog.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj b/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj index 3401da2d4a..31b65dcc02 100644 --- a/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj +++ b/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Avalonia.FreeDesktop/DBusSystemDialog.cs b/src/Avalonia.FreeDesktop/DBusSystemDialog.cs index 20583dd6ac..cd6f829d7a 100644 --- a/src/Avalonia.FreeDesktop/DBusSystemDialog.cs +++ b/src/Avalonia.FreeDesktop/DBusSystemDialog.cs @@ -21,7 +21,7 @@ namespace Avalonia.FreeDesktop var dbusFileChooser = new OrgFreedesktopPortalFileChooser(DBusHelper.Connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop"); try { - await dbusFileChooser.GetVersionAsync(); + await dbusFileChooser.GetVersionPropertyAsync(); } catch { From ea779ac9ad7171593bb1e5a61ab5ac49433e07ec Mon Sep 17 00:00:00 2001 From: Giuseppe Lippolis Date: Mon, 8 May 2023 18:13:31 +0200 Subject: [PATCH 23/40] feat: Enable Rule CA1854 --- .editorconfig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index d07618df6c..62a533e468 100644 --- a/.editorconfig +++ b/.editorconfig @@ -177,7 +177,9 @@ dotnet_diagnostic.CA1828.severity = warning dotnet_diagnostic.CA1829.severity = warning #CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters dotnet_diagnostic.CA1847.severity = warning -#CACA2211:Non-constant fields should not be visible +#CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method +dotnet_diagnostic.CA1854.severity = warning +#CA2211:Non-constant fields should not be visible dotnet_diagnostic.CA2211.severity = error # Wrapping preferences From e439b706a5cb3914b0619bdc624d7c23e30af977 Mon Sep 17 00:00:00 2001 From: Giuseppe Lippolis Date: Mon, 8 May 2023 18:13:54 +0200 Subject: [PATCH 24/40] feat: Address rule CA1854 --- .../Diagnostics/Views/MainWindow.xaml.cs | 4 ++-- src/Avalonia.Remote.Protocol/MetsysBson.cs | 9 ++++++--- src/Windows/Avalonia.Win32/TrayIconImpl.cs | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs index 4768c88f75..6687c91e4b 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs @@ -237,9 +237,9 @@ namespace Avalonia.Diagnostics.Views else { //TODO Use Dictionary.Remove(Key, out Value) in netstandard 2.1 - if (_frozenPopupStates.ContainsKey(popup)) + if (_frozenPopupStates.TryGetValue(popup,out var value)) { - _frozenPopupStates[popup].Dispose(); + value.Dispose(); _frozenPopupStates.Remove(popup); } } diff --git a/src/Avalonia.Remote.Protocol/MetsysBson.cs b/src/Avalonia.Remote.Protocol/MetsysBson.cs index c0263b3518..8966dd4206 100644 --- a/src/Avalonia.Remote.Protocol/MetsysBson.cs +++ b/src/Avalonia.Remote.Protocol/MetsysBson.cs @@ -715,7 +715,8 @@ namespace Metsys.Bson public MagicProperty FindProperty(string name) { - return _properties.ContainsKey(name) ? _properties[name] : null; + _properties.TryGetValue(name, out var property); + return property; } public static TypeHelper GetHelperForType(Type type) @@ -1196,7 +1197,9 @@ namespace Metsys.Bson } object container = null; var property = typeHelper.FindProperty(name); - var propertyType = property != null ? property.Type : _typeMap.ContainsKey(storageType) ? _typeMap[storageType] : typeof(object); + var propertyType = property?.Type + ?? (_typeMap.TryGetValue(storageType, out var type1) ? type1 : null) + ?? typeof(object); if (property != null && property.Setter == null) { container = property.Getter(instance); @@ -1588,7 +1591,7 @@ namespace Metsys.Bson.Configuration { return property; } - return map.ContainsKey(property) ? map[property] : property; + return map.TryGetValue(property, out var value) ? value : property; } public void AddIgnore(string name) diff --git a/src/Windows/Avalonia.Win32/TrayIconImpl.cs b/src/Windows/Avalonia.Win32/TrayIconImpl.cs index 8f9fc5fa80..d541e6b436 100644 --- a/src/Windows/Avalonia.Win32/TrayIconImpl.cs +++ b/src/Windows/Avalonia.Win32/TrayIconImpl.cs @@ -41,9 +41,9 @@ namespace Avalonia.Win32 internal static void ProcWnd(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { - if (msg == (int)CustomWindowsMessage.WM_TRAYMOUSE && s_trayIcons.ContainsKey(wParam.ToInt32())) + if (msg == (int)CustomWindowsMessage.WM_TRAYMOUSE && s_trayIcons.TryGetValue(wParam.ToInt32(), out var value)) { - s_trayIcons[wParam.ToInt32()].WndProc(hWnd, msg, wParam, lParam); + value.WndProc(hWnd, msg, wParam, lParam); } if (msg == WM_TASKBARCREATED) From c111c05bc2876541bebc1bf37017f30b6770d4eb Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 8 May 2023 16:51:45 -0400 Subject: [PATCH 25/40] Do not build browser samples --- dirs.proj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dirs.proj b/dirs.proj index f1eaae8a4a..eacf015349 100644 --- a/dirs.proj +++ b/dirs.proj @@ -12,7 +12,7 @@ - + From 4198ab3a32de7ca5fbe289a2b99394187b35f98b Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 8 May 2023 16:51:56 -0400 Subject: [PATCH 26/40] Backport some WCT DataGrid fixes --- .../DataGridColumns.cs | 14 +++++++++++++- src/Avalonia.Controls.DataGrid/DataGridRows.cs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumns.cs b/src/Avalonia.Controls.DataGrid/DataGridColumns.cs index 703bc0d9c3..4056b78bfe 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumns.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumns.cs @@ -12,6 +12,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Reflection; +using Avalonia.Layout; namespace Avalonia.Controls { @@ -489,7 +490,7 @@ namespace Avalonia.Controls { DataGridFillerColumn fillerColumn = ColumnsInternal.FillerColumn; double totalColumnsWidth = ColumnsInternal.VisibleEdgedColumnsWidth; - if (finalWidth > totalColumnsWidth) + if (finalWidth - totalColumnsWidth > LayoutHelper.LayoutEpsilon) { fillerColumn.FillerWidth = finalWidth - totalColumnsWidth; } @@ -971,6 +972,12 @@ namespace Avalonia.Controls { cx += _negHorizontalOffset; _horizontalOffset -= _negHorizontalOffset; + if (_horizontalOffset < LayoutHelper.LayoutEpsilon) + { + // Snap to zero to avoid trying to partially scroll in first scrolled off column below + _horizontalOffset = 0; + } + _negHorizontalOffset = 0; } else @@ -979,6 +986,11 @@ namespace Avalonia.Controls _negHorizontalOffset -= displayWidth - cx; cx = displayWidth; } + + // Make sure the HorizontalAdjustment is not greater than the new HorizontalOffset + // since it would cause an assertion failure in DataGridCellsPresenter.ShouldDisplayCell + // called by DataGridCellsPresenter.MeasureOverride. + HorizontalAdjustment = Math.Min(HorizontalAdjustment, _horizontalOffset); } // second try to scroll entire columns if (cx < displayWidth && _horizontalOffset > 0) diff --git a/src/Avalonia.Controls.DataGrid/DataGridRows.cs b/src/Avalonia.Controls.DataGrid/DataGridRows.cs index 00e035270c..44079d24d0 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridRows.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridRows.cs @@ -1589,6 +1589,23 @@ namespace Avalonia.Controls CorrectSlotsAfterDeletion(slot, isRow); OnRemovedElement(slot, item); + + // Synchronize CurrentCellCoordinates, CurrentColumn, CurrentColumnIndex, CurrentItem + // and CurrentSlot with the currently edited cell, since OnRemovingElement called + // SetCurrentCellCore(-1, -1) to temporarily reset the current cell. + if (_temporarilyResetCurrentCell && + _editingColumnIndex != -1 && + _previousCurrentItem != null && + EditingRow != null && + EditingRow.Slot != -1) + { + ProcessSelectionAndCurrency( + columnIndex: _editingColumnIndex, + item: _previousCurrentItem, + backupSlot: this.EditingRow.Slot, + action: DataGridSelectionAction.None, + scrollIntoView: false); + } } private void RemoveNonDisplayedRows(int newFirstDisplayedSlot, int newLastDisplayedSlot) From dd8f138a2f0ee0b089c42a3da98e56c739d7c145 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 8 May 2023 16:58:33 -0400 Subject: [PATCH 27/40] Potential fix for EndCellEdit --- src/Avalonia.Controls.DataGrid/DataGrid.cs | 28 ++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index e56e32a5ff..021618c643 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -4039,18 +4039,22 @@ namespace Avalonia.Controls return true; } - Debug.Assert(EditingRow != null); + var editingRow = EditingRow; + if (editingRow is null) + { + return false; + } + Debug.Assert(_editingColumnIndex >= 0); Debug.Assert(_editingColumnIndex < ColumnsItemsInternal.Count); Debug.Assert(_editingColumnIndex == CurrentColumnIndex); - Debug.Assert(EditingRow != null && EditingRow.Slot == CurrentSlot); // Cache these to see if they change later int currentSlot = CurrentSlot; int currentColumnIndex = CurrentColumnIndex; // We're ready to start ending, so raise the event - DataGridCell editingCell = EditingRow.Cells[_editingColumnIndex]; + DataGridCell editingCell = editingRow.Cells[_editingColumnIndex]; var editingElement = editingCell.Content as Control; if (editingElement == null) { @@ -4058,7 +4062,7 @@ namespace Avalonia.Controls } if (raiseEvents) { - DataGridCellEditEndingEventArgs e = new DataGridCellEditEndingEventArgs(CurrentColumn, EditingRow, editingElement, editAction); + DataGridCellEditEndingEventArgs e = new DataGridCellEditEndingEventArgs(CurrentColumn, editingRow, editingElement, editAction); OnCellEditEnding(e); if (e.Cancel) { @@ -4112,7 +4116,7 @@ namespace Avalonia.Controls } else { - if (EditingRow != null) + if (editingRow != null) { if (editingCell.IsValid) { @@ -4120,10 +4124,10 @@ namespace Avalonia.Controls editingCell.UpdatePseudoClasses(); } - if (EditingRow.IsValid) + if (editingRow.IsValid) { - EditingRow.IsValid = false; - EditingRow.UpdatePseudoClasses(); + editingRow.IsValid = false; + editingRow.UpdatePseudoClasses(); } } @@ -4169,22 +4173,22 @@ namespace Avalonia.Controls PopulateCellContent( isCellEdited: !exitEditingMode, dataGridColumn: CurrentColumn, - dataGridRow: EditingRow, + dataGridRow: editingRow, dataGridCell: editingCell); - EditingRow.InvalidateDesiredHeight(); + editingRow.InvalidateDesiredHeight(); var column = editingCell.OwningColumn; if (column.Width.IsSizeToCells || column.Width.IsAuto) {// Invalidate desired width and force recalculation column.SetWidthDesiredValue(0); - EditingRow.OwningGrid.AutoSizeColumn(column, editingCell.DesiredSize.Width); + editingRow.OwningGrid.AutoSizeColumn(column, editingCell.DesiredSize.Width); } } // We're done, so raise the CellEditEnded event if (raiseEvents) { - OnCellEditEnded(new DataGridCellEditEndedEventArgs(CurrentColumn, EditingRow, editAction)); + OnCellEditEnded(new DataGridCellEditEndedEventArgs(CurrentColumn, editingRow, editAction)); } // There's a chance that somebody reopened this cell for edit within the CellEditEnded handler, From 440be6e9da06efe4621bcafb502fe31f0714fe72 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 8 May 2023 17:01:00 -0400 Subject: [PATCH 28/40] Update excludsions list --- dirs.proj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dirs.proj b/dirs.proj index eacf015349..d29aa61fcb 100644 --- a/dirs.proj +++ b/dirs.proj @@ -9,10 +9,11 @@ - + + From 881dbfa62a180721ddb778f263caa6465cfa7243 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 8 May 2023 17:59:51 -0400 Subject: [PATCH 29/40] More mInor changes --- src/Avalonia.Controls.DataGrid/DataGrid.cs | 7 +++---- src/Avalonia.Controls.DataGrid/Utils/TreeHelper.cs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index 021618c643..e185c8e201 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -3734,7 +3734,7 @@ namespace Avalonia.Controls if (sender is Control editingElement) { editingElement.LostFocus -= EditingElement_LostFocus; - if (EditingRow != null && EditingColumnIndex != -1) + if (EditingRow != null && _editingColumnIndex != -1) { FocusEditingCell(true); } @@ -4042,7 +4042,7 @@ namespace Avalonia.Controls var editingRow = EditingRow; if (editingRow is null) { - return false; + return true; } Debug.Assert(_editingColumnIndex >= 0); @@ -4431,8 +4431,7 @@ namespace Avalonia.Controls dataGridCell.Focus(); success = dataGridCell.ContainsFocusedElement(); } - //TODO Check - //success = dataGridCell.ContainsFocusedElement() ? true : dataGridCell.Focus(); + _focusEditingControl = !success; } return success; diff --git a/src/Avalonia.Controls.DataGrid/Utils/TreeHelper.cs b/src/Avalonia.Controls.DataGrid/Utils/TreeHelper.cs index 6aebf05d6b..d2c4a58a70 100644 --- a/src/Avalonia.Controls.DataGrid/Utils/TreeHelper.cs +++ b/src/Avalonia.Controls.DataGrid/Utils/TreeHelper.cs @@ -54,7 +54,7 @@ namespace Avalonia.Controls.Utils /// True if the currently focused element is within the visual tree of the parent internal static bool ContainsFocusedElement(this Visual element) { - return (element == null) ? false : element.ContainsChild(FocusManager.Instance.Current as Visual); + return element is InputElement { IsKeyboardFocusWithin: true }; } } } From 1ba06676fd732ab83c100cb75413d1fd2057543b Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 8 May 2023 20:48:11 -0400 Subject: [PATCH 30/40] Fix DataGrid focus related properties defaults --- src/Avalonia.Controls.DataGrid/DataGrid.cs | 6 ++++-- src/Avalonia.Controls.DataGrid/DataGridCell.cs | 8 ++++---- src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs | 1 + src/Avalonia.Controls.DataGrid/DataGridRow.cs | 1 + src/Avalonia.Controls.DataGrid/DataGridRowGroupHeader.cs | 4 ++-- src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml | 7 +------ src/Avalonia.Controls.DataGrid/Themes/Simple.xaml | 1 - 7 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index e185c8e201..a55a47fa53 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -729,6 +729,8 @@ namespace Avalonia.Controls RowDetailsTemplateProperty.Changed.AddClassHandler((x, e) => x.OnRowDetailsTemplateChanged(e)); RowDetailsVisibilityModeProperty.Changed.AddClassHandler((x, e) => x.OnRowDetailsVisibilityModeChanged(e)); AutoGenerateColumnsProperty.Changed.AddClassHandler((x, e) => x.OnAutoGenerateColumnsChanged(e)); + + FocusableProperty.OverrideDefaultValue(true); } /// @@ -2478,7 +2480,7 @@ namespace Avalonia.Controls if (_hScrollBar != null) { - //_hScrollBar.IsTabStop = false; + _hScrollBar.IsTabStop = false; _hScrollBar.Maximum = 0.0; _hScrollBar.Orientation = Orientation.Horizontal; _hScrollBar.IsVisible = false; @@ -2494,7 +2496,7 @@ namespace Avalonia.Controls if (_vScrollBar != null) { - //_vScrollBar.IsTabStop = false; + _vScrollBar.IsTabStop = false; _vScrollBar.Maximum = 0.0; _vScrollBar.Orientation = Orientation.Vertical; _vScrollBar.IsVisible = false; diff --git a/src/Avalonia.Controls.DataGrid/DataGridCell.cs b/src/Avalonia.Controls.DataGrid/DataGridCell.cs index dd802678d4..599bea056b 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridCell.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridCell.cs @@ -33,6 +33,8 @@ namespace Avalonia.Controls { PointerPressedEvent.AddClassHandler( (x,e) => x.DataGridCell_PointerPressed(e), handledEventsToo: true); + FocusableProperty.OverrideDefaultValue(true); + IsTabStopProperty.OverrideDefaultValue(false); } public DataGridCell() { } @@ -169,8 +171,7 @@ namespace Avalonia.Controls OwningGrid.OnCellPointerPressed(new DataGridCellPointerPressedEventArgs(this, OwningRow, OwningColumn, e)); if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - if (!e.Handled) - //if (!e.Handled && OwningGrid.IsTabStop) + if (!e.Handled && OwningGrid.IsTabStop) { OwningGrid.Focus(); } @@ -190,8 +191,7 @@ namespace Avalonia.Controls } else if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed) { - if (!e.Handled) - //if (!e.Handled && OwningGrid.IsTabStop) + if (!e.Handled && OwningGrid.IsTabStop) { OwningGrid.Focus(); } diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs b/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs index 5250f80f77..ef1e84c745 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs @@ -72,6 +72,7 @@ namespace Avalonia.Controls { AreSeparatorsVisibleProperty.Changed.AddClassHandler((x, e) => x.OnAreSeparatorsVisibleChanged(e)); PressedMixin.Attach(); + IsTabStopProperty.OverrideDefaultValue(false); } /// diff --git a/src/Avalonia.Controls.DataGrid/DataGridRow.cs b/src/Avalonia.Controls.DataGrid/DataGridRow.cs index ea9b2fe972..dfda7d6e4f 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridRow.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridRow.cs @@ -128,6 +128,7 @@ namespace Avalonia.Controls DetailsTemplateProperty.Changed.AddClassHandler((x, e) => x.OnDetailsTemplateChanged(e)); AreDetailsVisibleProperty.Changed.AddClassHandler((x, e) => x.OnAreDetailsVisibleChanged(e)); PointerPressedEvent.AddClassHandler((x, e) => x.DataGridRow_PointerPressed(e), handledEventsToo: true); + IsTabStopProperty.OverrideDefaultValue(false); } /// diff --git a/src/Avalonia.Controls.DataGrid/DataGridRowGroupHeader.cs b/src/Avalonia.Controls.DataGrid/DataGridRowGroupHeader.cs index 10efded58a..e51c2526b1 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridRowGroupHeader.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridRowGroupHeader.cs @@ -106,6 +106,7 @@ namespace Avalonia.Controls { SublevelIndentProperty.Changed.AddClassHandler((x,e) => x.OnSublevelIndentChanged(e)); PressedMixin.Attach(); + IsTabStopProperty.OverrideDefaultValue(false); } /// @@ -301,8 +302,7 @@ namespace Avalonia.Controls } else { - //if (!e.Handled && OwningGrid.IsTabStop) - if (!e.Handled) + if (!e.Handled && OwningGrid.IsTabStop) { OwningGrid.Focus(); } diff --git a/src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml b/src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml index e4642c1453..0c183091dd 100644 --- a/src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml +++ b/src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml @@ -82,7 +82,6 @@ - - @@ -268,7 +266,6 @@ - @@ -310,7 +307,6 @@ - @@ -408,7 +404,6 @@ - @@ -433,7 +428,7 @@ BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}" CornerRadius="{TemplateBinding CornerRadius}" - Focusable="False" + IsTabStop="False" Foreground="{TemplateBinding Foreground}" /> - Date: Mon, 8 May 2023 20:54:49 -0400 Subject: [PATCH 31/40] DataGrid shouldn't have an annoying adorner when anything is focused inside --- src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml b/src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml index 0c183091dd..082eac60be 100644 --- a/src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml +++ b/src/Avalonia.Controls.DataGrid/Themes/Fluent.xaml @@ -498,6 +498,7 @@ +