Browse Source
* Basic infra for using headless platform in normal tests * Add RunJobs helper to scope returned from HeadlessUnitTestApplicationpull/22115/head
committed by
GitHub
2 changed files with 233 additions and 0 deletions
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue