Browse Source

Basic infra to use headless platform in "normal" unit tests (#22078)

* Basic infra for using headless platform in normal tests

* Add RunJobs helper to scope returned from HeadlessUnitTestApplication
pull/22115/head
Nikita Tsukanov 4 weeks ago
committed by GitHub
parent
commit
6896f08f37
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 150
      tests/Avalonia.Controls.UnitTests/HeadlessProbeTests.cs
  2. 83
      tests/Avalonia.UnitTests/HeadlessUnitTestApplication.cs

150
tests/Avalonia.Controls.UnitTests/HeadlessProbeTests.cs

@ -0,0 +1,150 @@
using System;
using System.Linq;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Platform;
using Avalonia.Rendering;
using Avalonia.Threading;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Xunit;
namespace Avalonia.Controls.UnitTests;
public class HeadlessProbeTests : ScopedTestBase
{
[Fact]
public void Can_Show_Window_And_Lay_Out()
{
using var app = HeadlessUnitTestApplication.Start();
var button = new Button { Width = 100, Height = 30, Content = "hi" };
var window = new Window { Width = 400, Height = 300, Content = button };
window.Show();
app.RunJobs();
Assert.Equal(new Size(100, 30), button.Bounds.Size);
Assert.Equal(new Size(400, 300), window.ClientSize);
}
[Fact]
public void Can_Route_Real_Input_Through_Headless_Toplevel()
{
using var app = HeadlessUnitTestApplication.Start();
var clicked = false;
var button = new Button { Width = 100, Height = 30, Content = "hi" };
button.Click += (_, _) => clicked = true;
var window = new Window { Width = 400, Height = 300, Content = button };
window.Show();
app.RunJobs();
var pt = button.Bounds.Center;
window.MouseDown(pt, MouseButton.Left);
window.MouseUp(pt, MouseButton.Left);
app.RunJobs();
Assert.True(clicked);
}
[Fact]
public void Keyboard_Input_Reaches_Focused_Control()
{
using var app = HeadlessUnitTestApplication.Start();
var box = new TextBox { Width = 200, Height = 30 };
var window = new Window { Width = 400, Height = 300, Content = box };
window.Show();
app.RunJobs();
box.Focus();
app.RunJobs();
window.KeyTextInput("abc");
app.RunJobs();
Assert.Equal("abc", box.Text);
}
[Fact]
public void Popup_Opens_As_Real_PopupRoot()
{
using var app = HeadlessUnitTestApplication.Start();
var popup = new Popup { Placement = PlacementMode.Pointer, Child = new Border { Width = 50, Height = 50 } };
var window = new Window { Width = 400, Height = 300, Content = new Panel { Children = { popup } } };
window.Show();
app.RunJobs();
popup.Open();
app.RunJobs();
Assert.IsType<PopupRoot>(popup.Host);
}
[Fact]
public void Popup_Uses_OverlayPopupHost_When_OverlayPopups_Enabled()
{
using var app = HeadlessUnitTestApplication.Start(new AvaloniaHeadlessPlatformOptions { OverlayPopups = true });
var popup = new Popup { Placement = PlacementMode.Pointer, Child = new Border { Width = 50, Height = 50 } };
var window = new Window { Width = 400, Height = 300, Content = new Panel { Children = { popup } } };
window.Show();
app.RunJobs();
popup.Open();
app.RunJobs();
Assert.IsType<OverlayPopupHost>(popup.Host);
}
[Fact]
public void Overlay_Popup_Requires_An_Applied_Window_Template()
{
using var app = HeadlessUnitTestApplication.Start(new AvaloniaHeadlessPlatformOptions { OverlayPopups = true });
// The overlay layer is looked up through the visual tree, so a window that was
// never shown has nothing to find.
var untemplated = new Popup { PlacementTarget = new Window() };
var ex = Assert.Throws<InvalidOperationException>(() => untemplated.Open());
Assert.Contains("no overlay layer is found", ex.Message);
var window = new Window();
window.Show();
app.RunJobs();
Assert.NotNull(window.GetVisualDescendants().OfType<VisualLayerManager>().FirstOrDefault());
var templated = new Popup { PlacementTarget = window };
templated.Open();
Assert.IsType<OverlayPopupHost>(templated.Host);
}
[Fact]
public void Platform_Services_Are_Registered_Before_The_First_Window_And_Stay_Stable()
{
using var app = HeadlessUnitTestApplication.Start();
object?[] Resolve() =>
[
AvaloniaLocator.Current.GetService<IKeyboardDevice>(),
AvaloniaLocator.Current.GetService<IPlatformSettings>(),
AvaloniaLocator.Current.GetService<ICursorFactory>(),
AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>(),
AvaloniaLocator.Current.GetService<IClipboard>(),
AvaloniaLocator.Current.GetService<IRenderLoop>(),
AvaloniaLocator.Current.GetService<IPlatformIconLoader>(),
AvaloniaLocator.Current.GetService<KeyGestureFormatInfo>(),
];
var before = Resolve();
Assert.All(before, Assert.NotNull);
new Window().Show();
app.RunJobs();
// Initializing the platform lazily used to replace these mid-test, leaving anything that
// resolved early holding a different instance than the window does.
Assert.Equal(before, Resolve(), System.Collections.Generic.ReferenceEqualityComparer.Instance);
}
}

83
tests/Avalonia.UnitTests/HeadlessUnitTestApplication.cs

@ -0,0 +1,83 @@
using System;
using System.Threading;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Themes.Simple;
using Avalonia.Threading;
using Xunit;
namespace Avalonia.UnitTests;
/// <summary>
/// Test application running on the real headless platform, set up through <see cref="AppBuilder"/>
/// so that the platform owns its own slice of the locator.
/// </summary>
/// <remarks>
/// This is deliberately not a <see cref="TestServices"/> flavour: the headless platform registers
/// services of its own (keyboard device, clipboard, render loop, platform settings, hotkey config),
/// and <see cref="UnitTestApplication"/> binds the same keys from its service bag, so the two
/// overwrite each other depending on when the platform happens to initialize.
/// </remarks>
public class HeadlessUnitTestApplication : Application
{
public HeadlessUnitTestApplication()
{
Styles.Add(new SimpleTheme());
}
public static Scope Start(AvaloniaHeadlessPlatformOptions? options = null)
{
var scope = AvaloniaLocator.EnterScope();
var oldContext = SynchronizationContext.Current;
try
{
Dispatcher.ResetBeforeUnitTests();
AppBuilder.Configure<HeadlessUnitTestApplication>()
// Popups default to dedicated top-levels here, matching the desktop platforms
// and the app used by Avalonia.Headless.UnitTests.
.UseHeadless(options ?? new AvaloniaHeadlessPlatformOptions { OverlayPopups = false })
.AfterPlatformServicesSetup(_ => AvaloniaLocator.CurrentMutable
.Bind<IFontManagerImpl>().ToConstant(new TestFontManager()))
.SetupUnsafe();
}
catch
{
scope.Dispose();
throw;
}
return new Scope(scope, oldContext);
}
public sealed class Scope(IDisposable locatorScope, SynchronizationContext? oldContext) : IDisposable
{
/// <summary>
/// Runs pending dispatcher jobs, tied to the test's cancellation token. Headless top-levels
/// post activation, resizes and rendering, so tests have to let those settle between acts.
/// </summary>
public void RunJobs(DispatcherPriority? priority = null) =>
Dispatcher.UIThread.RunJobs(priority, TestContext.Current.CancellationToken);
public void Dispose()
{
if (Dispatcher.UIThread.CheckAccess())
{
Dispatcher.UIThread.RunJobs();
}
(AvaloniaLocator.Current.GetService<IToolTipService>() as ToolTipService)?.Dispose();
(AvaloniaLocator.Current.GetService<FontManager>() as IDisposable)?.Dispose();
(AvaloniaLocator.Current.GetService<IInputManager>() as IDisposable)?.Dispose();
Dispatcher.ResetForUnitTests();
locatorScope.Dispose();
Dispatcher.ResetBeforeUnitTests();
SynchronizationContext.SetSynchronizationContext(oldContext);
}
}
}
Loading…
Cancel
Save