32 changed files with 698 additions and 187 deletions
@ -0,0 +1,27 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Platform.Storage.FileIO; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
internal class NoopStorageProvider : BclStorageProvider |
|||
{ |
|||
public override bool CanOpen => false; |
|||
public override Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
|||
{ |
|||
return Task.FromResult<IReadOnlyList<IStorageFile>>(Array.Empty<IStorageFile>()); |
|||
} |
|||
|
|||
public override bool CanSave => false; |
|||
public override Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
|||
{ |
|||
return Task.FromResult<IStorageFile?>(null); |
|||
} |
|||
|
|||
public override bool CanPickFolder => false; |
|||
public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
|||
{ |
|||
return Task.FromResult<IReadOnlyList<IStorageFolder>>(Array.Empty<IStorageFolder>()); |
|||
} |
|||
} |
|||
@ -1,13 +0,0 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
<PropertyGroup> |
|||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks> |
|||
|
|||
</PropertyGroup> |
|||
<ItemGroup> |
|||
<ProjectReference Include="..\..\packages\Avalonia\Avalonia.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
<Import Project="..\..\build\ApiDiff.props" /> |
|||
<Import Project="..\..\build\DevAnalyzers.props" /> |
|||
<Import Project="..\..\build\TrimmingEnable.props" /> |
|||
</Project> |
|||
@ -0,0 +1,19 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
<PropertyGroup> |
|||
<TargetFramework>net6.0</TargetFramework> |
|||
<ImplicitUsings>enable</ImplicitUsings> |
|||
<Nullable>enable</Nullable> |
|||
</PropertyGroup> |
|||
|
|||
<ItemGroup> |
|||
<PackageReference Include="xunit.core" Version="2.4.2" /> |
|||
</ItemGroup> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\Avalonia.Headless\Avalonia.Headless.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
<Import Project="..\..\..\build\ApiDiff.props" /> |
|||
<Import Project="..\..\..\build\DevAnalyzers.props" /> |
|||
<Import Project="..\..\..\build\NullableEnable.props" /> |
|||
</Project> |
|||
@ -0,0 +1,35 @@ |
|||
using System.Reflection; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
internal class AvaloniaTestFramework<TAppBuilderEntry> : XunitTestFramework |
|||
{ |
|||
public AvaloniaTestFramework(IMessageSink messageSink) : base(messageSink) |
|||
{ |
|||
} |
|||
|
|||
protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName) |
|||
=> new Executor(assemblyName, SourceInformationProvider, DiagnosticMessageSink); |
|||
|
|||
|
|||
private class Executor : XunitTestFrameworkExecutor |
|||
{ |
|||
public Executor(AssemblyName assemblyName, ISourceInformationProvider sourceInformationProvider, |
|||
IMessageSink diagnosticMessageSink) : base(assemblyName, sourceInformationProvider, |
|||
diagnosticMessageSink) |
|||
{ |
|||
} |
|||
|
|||
protected override async void RunTestCases(IEnumerable<IXunitTestCase> testCases, |
|||
IMessageSink executionMessageSink, |
|||
ITestFrameworkExecutionOptions executionOptions) |
|||
{ |
|||
executionOptions.SetValue("xunit.execution.DisableParallelization", false); |
|||
using (var assemblyRunner = new AvaloniaTestRunner<TAppBuilderEntry>( |
|||
TestAssembly, testCases, DiagnosticMessageSink, executionMessageSink, |
|||
executionOptions)) await assemblyRunner.RunAsync(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
/// <summary>
|
|||
/// Sets up global avalonia test framework using avalonia application builder passed as a parameter.
|
|||
/// </summary>
|
|||
[TestFrameworkDiscoverer("Avalonia.Headless.XUnit.AvaloniaTestFrameworkTypeDiscoverer", "Avalonia.Headless.XUnit")] |
|||
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] |
|||
public sealed class AvaloniaTestFrameworkAttribute : Attribute, ITestFrameworkAttribute |
|||
{ |
|||
/// <summary>
|
|||
/// Creates instance of <see cref="AvaloniaTestFrameworkAttribute"/>.
|
|||
/// </summary>
|
|||
/// <param name="appBuilderEntryPointType">
|
|||
/// Parameter from which <see cref="AppBuilder"/> should be created.
|
|||
/// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application.
|
|||
/// </param>
|
|||
public AvaloniaTestFrameworkAttribute( |
|||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] |
|||
Type appBuilderEntryPointType) { } |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Discoverer implementation for the Avalonia testing framework.
|
|||
/// </summary>
|
|||
public class AvaloniaTestFrameworkTypeDiscoverer : ITestFrameworkTypeDiscoverer |
|||
{ |
|||
/// <summary>
|
|||
/// Creates instance of <see cref="AvaloniaTestFrameworkTypeDiscoverer"/>.
|
|||
/// </summary>
|
|||
public AvaloniaTestFrameworkTypeDiscoverer(IMessageSink _) |
|||
{ |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
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); |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
using Avalonia.Threading; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
internal class AvaloniaTestRunner<TAppBuilderEntry> : XunitTestAssemblyRunner |
|||
{ |
|||
private CancellationTokenSource? _cancellationTokenSource; |
|||
|
|||
public AvaloniaTestRunner(ITestAssembly testAssembly, IEnumerable<IXunitTestCase> testCases, |
|||
IMessageSink diagnosticMessageSink, IMessageSink executionMessageSink, |
|||
ITestFrameworkExecutionOptions executionOptions) : base(testAssembly, testCases, diagnosticMessageSink, |
|||
executionMessageSink, executionOptions) |
|||
{ |
|||
} |
|||
|
|||
protected override void SetupSyncContext(int maxParallelThreads) |
|||
{ |
|||
_cancellationTokenSource?.Dispose(); |
|||
_cancellationTokenSource = new CancellationTokenSource(); |
|||
SynchronizationContext.SetSynchronizationContext(InitNewApplicationContext(_cancellationTokenSource.Token).Result); |
|||
} |
|||
|
|||
public override void Dispose() |
|||
{ |
|||
_cancellationTokenSource?.Cancel(); |
|||
base.Dispose(); |
|||
} |
|||
|
|||
internal static Task<SynchronizationContext> InitNewApplicationContext(CancellationToken cancellationToken) |
|||
{ |
|||
var tcs = new TaskCompletionSource<SynchronizationContext>(); |
|||
|
|||
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; |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
<PropertyGroup> |
|||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks> |
|||
</PropertyGroup> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\..\..\packages\Avalonia\Avalonia.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
<Import Project="..\..\..\build\ApiDiff.props" /> |
|||
<Import Project="..\..\..\build\DevAnalyzers.props" /> |
|||
<Import Project="..\..\..\build\TrimmingEnable.props" /> |
|||
<Import Project="..\..\..\build\NullableEnable.props" /> |
|||
|
|||
<ItemGroup Label="InternalsVisibleTo"> |
|||
<InternalsVisibleTo Include="Avalonia.Headless.Vnc, PublicKey=$(AvaloniaPublicKey)" /> |
|||
</ItemGroup> |
|||
</Project> |
|||
@ -0,0 +1,101 @@ |
|||
using System; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Input; |
|||
using Avalonia.Input.Raw; |
|||
using Avalonia.Media.Imaging; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Threading; |
|||
|
|||
namespace Avalonia.Headless; |
|||
|
|||
/// <summary>
|
|||
/// Set of extension methods to simplify usage of Avalonia.Headless platform.
|
|||
/// </summary>
|
|||
public static class HeadlessWindowExtensions |
|||
{ |
|||
/// <summary>
|
|||
/// Triggers a renderer timer tick and captures last rendered frame.
|
|||
/// </summary>
|
|||
/// <returns>Bitmap with last rendered frame. Null, if nothing was rendered.</returns>
|
|||
public static Bitmap? CaptureRenderedFrame(this TopLevel topLevel) |
|||
{ |
|||
Dispatcher.UIThread.RunJobs(); |
|||
AvaloniaHeadlessPlatform.ForceRenderTimerTick(); |
|||
return topLevel.GetLastRenderedFrame(); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Reads last rendered frame.
|
|||
/// Note, in order to trigger rendering timer, call <see cref="AvaloniaHeadlessPlatform.ForceRenderTimerTick"/> method.
|
|||
/// </summary>
|
|||
/// <returns>Bitmap with last rendered frame. Null, if nothing was rendered.</returns>
|
|||
public static Bitmap? GetLastRenderedFrame(this TopLevel topLevel) |
|||
{ |
|||
if (AvaloniaLocator.Current.GetService<IPlatformRenderInterface>() is HeadlessPlatformRenderInterface) |
|||
{ |
|||
throw new NotSupportedException( |
|||
"To capture a rendered frame, make sure that headless application was initialized with '.UseSkia()' and disabled 'UseHeadlessDrawing' in the 'AvaloniaHeadlessPlatformOptions'."); |
|||
} |
|||
|
|||
return GetImpl(topLevel).GetLastRenderedFrame(); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Simulates keyboard press on the headless window/toplevel.
|
|||
/// </summary>
|
|||
public static void KeyPress(this TopLevel topLevel, Key key, RawInputModifiers modifiers) => |
|||
RunJobsAndGetImpl(topLevel).KeyPress(key, modifiers); |
|||
|
|||
/// <summary>
|
|||
/// Simulates keyboard release on the headless window/toplevel.
|
|||
/// </summary>
|
|||
public static void KeyRelease(this TopLevel topLevel, Key key, RawInputModifiers modifiers) => |
|||
RunJobsAndGetImpl(topLevel).KeyRelease(key, modifiers); |
|||
|
|||
/// <summary>
|
|||
/// Simulates mouse down on the headless window/toplevel.
|
|||
/// </summary>
|
|||
public static void MouseDown(this TopLevel topLevel, Point point, MouseButton button, |
|||
RawInputModifiers modifiers = RawInputModifiers.None) => |
|||
RunJobsAndGetImpl(topLevel).MouseDown(point, button, modifiers); |
|||
|
|||
/// <summary>
|
|||
/// Simulates mouse move on the headless window/toplevel.
|
|||
/// </summary>
|
|||
public static void MouseMove(this TopLevel topLevel, Point point, |
|||
RawInputModifiers modifiers = RawInputModifiers.None) => |
|||
RunJobsAndGetImpl(topLevel).MouseMove(point, modifiers); |
|||
|
|||
/// <summary>
|
|||
/// Simulates mouse up on the headless window/toplevel.
|
|||
/// </summary>
|
|||
public static void MouseUp(this TopLevel topLevel, Point point, MouseButton button, |
|||
RawInputModifiers modifiers = RawInputModifiers.None) => |
|||
RunJobsAndGetImpl(topLevel).MouseUp(point, button, modifiers); |
|||
|
|||
/// <summary>
|
|||
/// Simulates mouse wheel on the headless window/toplevel.
|
|||
/// </summary>
|
|||
public static void MouseWheel(this TopLevel topLevel, Point point, Vector delta, |
|||
RawInputModifiers modifiers = RawInputModifiers.None) => |
|||
RunJobsAndGetImpl(topLevel).MouseWheel(point, delta, modifiers); |
|||
|
|||
/// <summary>
|
|||
/// Simulates drag'n'drop target on the headless window/toplevel.
|
|||
/// </summary>
|
|||
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); |
|||
|
|||
private static IHeadlessWindow RunJobsAndGetImpl(this TopLevel topLevel) |
|||
{ |
|||
Dispatcher.UIThread.RunJobs(); |
|||
return GetImpl(topLevel); |
|||
} |
|||
|
|||
private static IHeadlessWindow GetImpl(this TopLevel topLevel) |
|||
{ |
|||
return topLevel.PlatformImpl as IHeadlessWindow ?? |
|||
throw new InvalidOperationException("TopLevel must be a headless window."); |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
|
|||
<PropertyGroup> |
|||
<TargetFramework>net6.0</TargetFramework> |
|||
<IsTestProject>true</IsTestProject> |
|||
</PropertyGroup> |
|||
<Import Project="..\..\build\UnitTests.NetCore.targets" /> |
|||
<Import Project="..\..\build\UnitTests.NetFX.props" /> |
|||
<Import Project="..\..\build\Moq.props" /> |
|||
<Import Project="..\..\build\XUnit.props" /> |
|||
<Import Project="..\..\build\Rx.props" /> |
|||
<Import Project="..\..\build\SharedVersion.props" /> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\..\src\Avalonia.Themes.Simple\Avalonia.Themes.Simple.csproj" /> |
|||
<ProjectReference Include="..\..\src\Headless\Avalonia.Headless.XUnit\Avalonia.Headless.XUnit.csproj" /> |
|||
<ProjectReference Include="..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" /> |
|||
</ItemGroup> |
|||
</Project> |
|||
@ -0,0 +1,36 @@ |
|||
using Avalonia.Controls; |
|||
using Avalonia.Input; |
|||
using Avalonia.Layout; |
|||
using Avalonia.Threading; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Headless.UnitTests; |
|||
|
|||
public class InputTests |
|||
{ |
|||
[Fact] |
|||
public void Should_Click_Button_On_Window() |
|||
{ |
|||
var buttonClicked = false; |
|||
var button = new Button |
|||
{ |
|||
HorizontalAlignment = HorizontalAlignment.Stretch, |
|||
VerticalAlignment = VerticalAlignment.Stretch |
|||
}; |
|||
|
|||
button.Click += (_, _) => buttonClicked = true; |
|||
|
|||
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); |
|||
|
|||
Assert.True(buttonClicked); |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
using Avalonia.Controls; |
|||
using Avalonia.Layout; |
|||
using Avalonia.Media; |
|||
using Avalonia.Threading; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Headless.UnitTests; |
|||
|
|||
public class RenderingTests |
|||
{ |
|||
[Fact] |
|||
public void Should_Render_Last_Frame_To_Bitmap() |
|||
{ |
|||
var window = new Window |
|||
{ |
|||
Content = new ContentControl |
|||
{ |
|||
HorizontalAlignment = HorizontalAlignment.Stretch, |
|||
VerticalAlignment = VerticalAlignment.Stretch, |
|||
Padding = new Thickness(4), |
|||
Content = new PathIcon |
|||
{ |
|||
Data = StreamGeometry.Parse("M0,9 L10,0 20,9 19,10 10,2 1,10 z") |
|||
} |
|||
}, |
|||
SizeToContent = SizeToContent.WidthAndHeight |
|||
}; |
|||
window.Show(); |
|||
|
|||
var frame = window.CaptureRenderedFrame(); |
|||
Assert.NotNull(frame); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
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; |
|||
|
|||
public class TestApplication : Application |
|||
{ |
|||
public TestApplication() |
|||
{ |
|||
Styles.Add(new SimpleTheme()); |
|||
} |
|||
|
|||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<TestApplication>() |
|||
.UseSkia() |
|||
.UseHeadless(new AvaloniaHeadlessPlatformOptions |
|||
{ |
|||
UseHeadlessDrawing = false |
|||
}); |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
using System; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Threading; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Headless.UnitTests; |
|||
|
|||
public class ThreadingTests |
|||
{ |
|||
[Fact] |
|||
public void Should_Be_On_Dispatcher_Thread() |
|||
{ |
|||
Dispatcher.UIThread.VerifyAccess(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task DispatcherTimer_Works_On_The_Same_Thread() |
|||
{ |
|||
var currentThread = Thread.CurrentThread; |
|||
var tcs = new TaskCompletionSource(); |
|||
|
|||
DispatcherTimer.RunOnce(() => |
|||
{ |
|||
Assert.Equal(currentThread, Thread.CurrentThread); |
|||
|
|||
tcs.SetResult(); |
|||
}, TimeSpan.FromTicks(1)); |
|||
|
|||
await tcs.Task; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue