Browse Source

Merge branch 'master' into wpf-integration

Nikita Tsukanov 8 years ago
parent
commit
41ed04f369
78 changed files with 1986 additions and 486 deletions
  1. 2 1
      .gitignore
  2. 13 12
      build.cake
  3. 1 1
      build/Moq.props
  4. 4 2
      build/XUnit.props
  5. 1 1
      docs/tutorial/from-wpf.md
  6. 1 0
      samples/interop/WindowsInteropTest/WindowsInteropTest.csproj
  7. 8 30
      src/Avalonia.Base/Data/BindingNotification.cs
  8. 23 13
      src/Avalonia.Controls/Button.cs
  9. 9 2
      src/Avalonia.Controls/Control.cs
  10. 1 1
      src/Avalonia.Controls/Menu.cs
  11. 10 1
      src/Avalonia.Controls/MenuItem.cs
  12. 24 14
      src/Avalonia.Controls/Presenters/ContentPresenter.cs
  13. 11 0
      src/Avalonia.Controls/Primitives/TemplatedControl.cs
  14. 26 14
      src/Avalonia.Controls/ToolTip.cs
  15. 21 1
      src/Avalonia.Controls/TreeView.cs
  16. 3 0
      src/Avalonia.Input/Avalonia.Input.csproj
  17. 3 2
      src/Avalonia.Input/FocusManager.cs
  18. 15 0
      src/Avalonia.Input/ICustomKeyboardNavigation.cs
  19. 27 0
      src/Avalonia.Input/KeyboardNavigationHandler.cs
  20. 14 2
      src/Avalonia.Input/Navigation/DirectionalNavigation.cs
  21. 62 25
      src/Avalonia.Input/Navigation/TabNavigation.cs
  22. 64 33
      src/Avalonia.Layout/LayoutManager.cs
  23. 12 4
      src/Avalonia.Layout/Layoutable.cs
  24. 10 0
      src/Avalonia.Styling/LogicalTree/ILogical.cs
  25. 2 2
      src/Avalonia.Styling/Styling/Style.cs
  26. 1 1
      src/Gtk/Avalonia.Gtk3/Interop/GObject.cs
  27. 2 0
      src/Gtk/Avalonia.Gtk3/Interop/Utf8Buffer.cs
  28. 2 1
      src/Gtk/Avalonia.Gtk3/SystemDialogs.cs
  29. 1 1
      src/Markup/Avalonia.Markup.Xaml/Data/MultiBinding.cs
  30. 2 2
      src/Markup/Avalonia.Markup/Data/BindingExpression.cs
  31. 1 0
      tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj
  32. 1 1
      tests/Avalonia.Base.UnitTests/Properties/AssemblyInfo.cs
  33. 2 1
      tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj
  34. 65 0
      tests/Avalonia.Benchmarks/Layout/Measure.cs
  35. 1 0
      tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs
  36. 4 0
      tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj
  37. 291 0
      tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_InTemplate.cs
  38. 11 179
      tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Standalone.cs
  39. 102 0
      tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Unrooted.cs
  40. 109 0
      tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs
  41. 28 0
      tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests.cs
  42. 14 5
      tests/Avalonia.Controls.UnitTests/TopLevelTests.cs
  43. 44 0
      tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
  44. 1 0
      tests/Avalonia.Input.UnitTests/Avalonia.Input.UnitTests.csproj
  45. 214 0
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs
  46. 1 0
      tests/Avalonia.Interactivity.UnitTests/Avalonia.Interactivity.UnitTests.csproj
  47. 1 0
      tests/Avalonia.Layout.UnitTests/Avalonia.Layout.UnitTests.csproj
  48. 265 7
      tests/Avalonia.Layout.UnitTests/LayoutManagerTests.cs
  49. 29 0
      tests/Avalonia.Layout.UnitTests/LayoutTestControl.cs
  50. 43 0
      tests/Avalonia.Layout.UnitTests/LayoutTestRoot.cs
  51. 90 0
      tests/Avalonia.Layout.UnitTests/LayoutableTests.cs
  52. 0 24
      tests/Avalonia.Layout.UnitTests/TestLayoutRoot.cs
  53. 1 0
      tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj
  54. 2 1
      tests/Avalonia.Markup.UnitTests/ControlLocatorTests.cs
  55. 55 60
      tests/Avalonia.Markup.UnitTests/Data/BindingExpressionTests.cs
  56. 3 2
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_AttachedProperty.cs
  57. 2 1
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_AvaloniaProperty.cs
  58. 9 0
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs
  59. 56 11
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs
  60. 4 1
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Lifetime.cs
  61. 24 7
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Negation.cs
  62. 12 0
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs
  63. 69 9
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs
  64. 12 0
      tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs
  65. 2 0
      tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs
  66. 1 1
      tests/Avalonia.Markup.UnitTests/Properties/AssemblyInfo.cs
  67. 1 0
      tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj
  68. 2 1
      tests/Avalonia.Markup.Xaml.UnitTests/Data/MultiBindingTests.cs
  69. 1 1
      tests/Avalonia.Markup.Xaml.UnitTests/Properties/AssemblyInfo.cs
  70. 1 0
      tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj
  71. 7 1
      tests/Avalonia.Styling.UnitTests/SelectorTests_Child.cs
  72. 5 0
      tests/Avalonia.Styling.UnitTests/SelectorTests_Descendent.cs
  73. 1 3
      tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj
  74. 12 3
      tests/Avalonia.UnitTests/InvariantCultureFixture.cs
  75. 1 1
      tests/Avalonia.UnitTests/TestRoot.cs
  76. 6 0
      tests/Avalonia.UnitTests/TestServices.cs
  77. 1 0
      tests/Avalonia.UnitTests/UnitTestApplication.cs
  78. 4 0
      tools/packages.config

+ 2 - 1
.gitignore

@@ -162,7 +162,8 @@ $RECYCLE.BIN/
 #################
 ## Cake
 #################
-tools/
+tools/*
+!tools/packages.config
 .nuget
 artifacts/
 nuget

+ 13 - 12
build.cake

@@ -11,7 +11,7 @@
 // TOOLS
 ///////////////////////////////////////////////////////////////////////////////
 
-#tool "nuget:?package=xunit.runner.console&version=2.1.0"
+#tool "nuget:?package=xunit.runner.console&version=2.2.0"
 #tool "nuget:?package=OpenCover"
 
 ///////////////////////////////////////////////////////////////////////////////
@@ -98,7 +98,6 @@ Task("Clean")
     CleanDirectory(parameters.TestsRoot);
 });
 
-
 Task("Restore-NuGet-Packages")
     .IsDependentOn("Clean")
     .WithCriteria(parameters.IsRunningOnWindows)
@@ -171,23 +170,25 @@ void RunCoreTest(string dir, Parameters parameters, bool net461Only)
             continue;
         Information("Running for " + fw);
         DotNetCoreTest(System.IO.Path.Combine(dir, System.IO.Path.GetFileName(dir)+".csproj"),
-            new DotNetCoreTestSettings{Framework = fw});
+            new DotNetCoreTestSettings {
+                Configuration = parameters.Configuration,
+                Framework = fw
+            });
     }
 }
 
-
 Task("Run-Net-Core-Unit-Tests")
     .IsDependentOn("Clean")
     .Does(() => {
         RunCoreTest("./tests/Avalonia.Base.UnitTests", parameters, false);
-        RunCoreTest("./tests/Avalonia.Controls.UnitTests", parameters, true);
-        RunCoreTest("./tests/Avalonia.Input.UnitTests", parameters, true);
-        RunCoreTest("./tests/Avalonia.Interactivity.UnitTests", parameters, true);
-        RunCoreTest("./tests/Avalonia.Layout.UnitTests", parameters, true);
-        //RunCoreTest("./tests/Avalonia.Markup.UnitTests", parameters, true);
-        //RunCoreTest("./tests/Avalonia.Markup.Xaml.UnitTests", parameters, true);
-        RunCoreTest("./tests/Avalonia.Styling.UnitTests", parameters, true);
-        RunCoreTest("./tests/Avalonia.Visuals.UnitTests", parameters, true);
+        RunCoreTest("./tests/Avalonia.Controls.UnitTests", parameters, false);
+        RunCoreTest("./tests/Avalonia.Input.UnitTests", parameters, false);
+        RunCoreTest("./tests/Avalonia.Interactivity.UnitTests", parameters, false);
+        RunCoreTest("./tests/Avalonia.Layout.UnitTests", parameters, false);
+        RunCoreTest("./tests/Avalonia.Markup.UnitTests", parameters, false);
+        RunCoreTest("./tests/Avalonia.Markup.Xaml.UnitTests", parameters, false);
+        RunCoreTest("./tests/Avalonia.Styling.UnitTests", parameters, false);
+        RunCoreTest("./tests/Avalonia.Visuals.UnitTests", parameters, false);
     });
 
 Task("Run-Unit-Tests")

+ 1 - 1
build/Moq.props

@@ -1,5 +1,5 @@
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup>
-    <PackageReference Include="Moq" Version="4.7.1" />
+    <PackageReference Include="Moq" Version="4.7.25" />
   </ItemGroup>
 </Project>

+ 4 - 2
build/XUnit.props

@@ -7,7 +7,9 @@
     <PackageReference Include="xunit.extensibility.core" Version="2.2.0" />
     <PackageReference Include="xunit.extensibility.execution" Version="2.2.0" />
     <PackageReference Include="xunit.runner.console" Version="2.2.0" />
-    <PackageReference Condition="'$(TargetFramework)' == 'net461'" Include="xunit.runner.visualstudio" Version="2.2.0" />
-    <PackageReference Condition="'$(TargetFramework)' == 'netcoreapp1.1'" Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" />
+  </ItemGroup>
+  <ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp1.1'">
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />
   </ItemGroup>
 </Project>

+ 1 - 1
docs/tutorial/from-wpf.md

@@ -33,7 +33,7 @@ placed in a `DataTemplates` collection on each control (and on `Application`):
                     <TextBox Text="{Binding Name}"/>
                 </Border>
             </DataTemplate>
-        </UserControl.Styles>
+        </UserControl.DataTemplates>
         <!-- Assuming that DataContext.Foo is an object of type
              MyApp.ViewModels.FooViewModel then a red border with a corner
              radius of 8 containing a TextBox will be displayed here -->

+ 1 - 0
samples/interop/WindowsInteropTest/WindowsInteropTest.csproj

@@ -185,5 +185,6 @@
   </ItemGroup>
   <Import Project="..\..\..\build\Rx.props" />
   <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
+  <Import Project="..\..\..\build\SkiaSharp.props" />
   <Import Project="$(MSBuildThisFileDirectory)..\..\..\src\Shared\nuget.workaround.targets" />
 </Project>

+ 8 - 30
src/Avalonia.Base/Data/BindingNotification.cs

@@ -44,11 +44,7 @@ namespace Avalonia.Data
         public static readonly BindingNotification UnsetValue =
             new BindingNotification(AvaloniaProperty.UnsetValue);
 
-        // Null cannot be held in WeakReference as it's indistinguishable from an expired value so
-        // use this value in its place.
-        private static readonly object NullValue = new object();
-
-        private WeakReference<object> _value;
+        private object _value;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="BindingNotification"/> class.
@@ -56,7 +52,7 @@ namespace Avalonia.Data
         /// <param name="value">The binding value.</param>
         public BindingNotification(object value)
         {
-            _value = new WeakReference<object>(value ?? NullValue);
+            _value = value;
         }
 
         /// <summary>
@@ -73,6 +69,7 @@ namespace Avalonia.Data
 
             Error = error;
             ErrorType = errorType;
+            _value = AvaloniaProperty.UnsetValue;
         }
 
         /// <summary>
@@ -84,7 +81,7 @@ namespace Avalonia.Data
         public BindingNotification(Exception error, BindingErrorType errorType, object fallbackValue)
             : this(error, errorType)
         {
-            _value = new WeakReference<object>(fallbackValue ?? NullValue);
+            _value = fallbackValue;
         }
 
         /// <summary>
@@ -95,31 +92,12 @@ namespace Avalonia.Data
         /// If this property is read when <see cref="HasValue"/> is false then it will return
         /// <see cref="AvaloniaProperty.UnsetValue"/>.
         /// </remarks>
-        public object Value
-        {
-            get
-            {
-                if (_value != null)
-                {
-                    object result;
-
-                    if (_value.TryGetTarget(out result))
-                    {
-                        return result == NullValue ? null : result;
-                    }
-                }
-
-                // There's the possibility of a race condition in that HasValue can return true,
-                // and then the value is GC'd before Value is read. We should be ok though as
-                // we return UnsetValue which should be a safe alternative.
-                return AvaloniaProperty.UnsetValue;
-            }
-        }
+        public object Value => _value;
 
         /// <summary>
         /// Gets a value indicating whether <see cref="Value"/> should be pushed to the target.
         /// </summary>
-        public bool HasValue => _value != null;
+        public bool HasValue => _value != AvaloniaProperty.UnsetValue;
 
         /// <summary>
         /// Gets the error that occurred on the source, if any.
@@ -248,7 +226,7 @@ namespace Avalonia.Data
         /// </summary>
         public void ClearValue()
         {
-            _value = null;
+            _value = AvaloniaProperty.UnsetValue;
         }
 
         /// <summary>
@@ -256,7 +234,7 @@ namespace Avalonia.Data
         /// </summary>
         public void SetValue(object value)
         {
-            _value = new WeakReference<object>(value ?? NullValue);
+            _value = value;
         }
 
         /// <inheritdoc/>

+ 23 - 13
src/Avalonia.Controls/Button.cs

@@ -207,7 +207,11 @@ namespace Avalonia.Controls
         /// <param name="e">The event args.</param>
         protected virtual void OnClick(RoutedEventArgs e)
         {
-            Command?.Execute(CommandParameter);
+            if (Command != null)
+            {
+                Command.Execute(CommandParameter);
+                e.Handled = true;
+            }
         }
 
         /// <inheritdoc/>
@@ -215,13 +219,16 @@ namespace Avalonia.Controls
         {
             base.OnPointerPressed(e);
 
-            PseudoClasses.Add(":pressed");
-            e.Device.Capture(this);
-            e.Handled = true;
-
-            if (ClickMode == ClickMode.Press)
+            if (e.MouseButton == MouseButton.Left)
             {
-                RaiseClickEvent();
+                PseudoClasses.Add(":pressed");
+                e.Device.Capture(this);
+                e.Handled = true;
+
+                if (ClickMode == ClickMode.Press)
+                {
+                    RaiseClickEvent();
+                }
             }
         }
 
@@ -230,13 +237,16 @@ namespace Avalonia.Controls
         {
             base.OnPointerReleased(e);
 
-            e.Device.Capture(null);
-            PseudoClasses.Remove(":pressed");
-            e.Handled = true;
-
-            if (ClickMode == ClickMode.Release && Classes.Contains(":pointerover"))
+            if (e.MouseButton == MouseButton.Left)
             {
-                RaiseClickEvent();
+                e.Device.Capture(null);
+                PseudoClasses.Remove(":pressed");
+                e.Handled = true;
+
+                if (ClickMode == ClickMode.Release && new Rect(Bounds.Size).Contains(e.GetPosition(this)))
+                {
+                    RaiseClickEvent();
+                }
             }
         }
 

+ 9 - 2
src/Avalonia.Controls/Control.cs

@@ -118,6 +118,7 @@ namespace Avalonia.Controls
         public Control()
         {
             _nameScope = this as INameScope;
+            _isAttachedToLogicalTree = this is IStyleRoot;
         }
 
         /// <summary>
@@ -369,6 +370,12 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <inheritdoc/>
+        void ILogical.NotifyAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
+        {
+            this.OnAttachedToLogicalTreeCore(e);
+        }
+
         /// <inheritdoc/>
         void ILogical.NotifyDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
         {
@@ -418,7 +425,7 @@ namespace Avalonia.Controls
 
                 if (_isAttachedToLogicalTree)
                 {
-                    var oldRoot = FindStyleRoot(old);
+                    var oldRoot = FindStyleRoot(old) ?? this as IStyleRoot;
 
                     if (oldRoot == null)
                     {
@@ -436,7 +443,7 @@ namespace Avalonia.Controls
 
                 _parent = (IControl)parent;
 
-                if (_parent is IStyleRoot || _parent?.IsAttachedToLogicalTree == true)
+                if (_parent is IStyleRoot || _parent?.IsAttachedToLogicalTree == true || this is IStyleRoot)
                 {
                     var newRoot = FindStyleRoot(this);
 

+ 1 - 1
src/Avalonia.Controls/Menu.cs

@@ -47,7 +47,7 @@ namespace Avalonia.Controls
         static Menu()
         {
             ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel);
-            MenuItem.ClickEvent.AddClassHandler<Menu>(x => x.OnMenuClick);
+            MenuItem.ClickEvent.AddClassHandler<Menu>(x => x.OnMenuClick, handledEventsToo: true);
             MenuItem.SubmenuOpenedEvent.AddClassHandler<Menu>(x => x.OnSubmenuOpened);
         }
 

+ 10 - 1
src/Avalonia.Controls/MenuItem.cs

@@ -102,6 +102,11 @@ namespace Avalonia.Controls
             AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<MenuItem>(x => x.AccessKeyPressed);
         }
 
+        public MenuItem()
+        {
+
+        }
+
         /// <summary>
         /// Occurs when a <see cref="MenuItem"/> without a submenu is clicked.
         /// </summary>
@@ -192,7 +197,11 @@ namespace Avalonia.Controls
         /// <param name="e">The click event args.</param>
         protected virtual void OnClick(RoutedEventArgs e)
         {
-            Command?.Execute(CommandParameter);
+            if (Command != null)
+            {
+                Command.Execute(CommandParameter);
+                e.Handled = true;
+            }
         }
 
         /// <summary>

+ 24 - 14
src/Avalonia.Controls/Presenters/ContentPresenter.cs

@@ -8,6 +8,7 @@ using Avalonia.Controls.Templates;
 using Avalonia.Layout;
 using Avalonia.LogicalTree;
 using Avalonia.Media;
+using Avalonia.VisualTree;
 
 namespace Avalonia.Controls.Presenters
 {
@@ -88,6 +89,7 @@ namespace Avalonia.Controls.Presenters
         static ContentPresenter()
         {
             ContentProperty.Changed.AddClassHandler<ContentPresenter>(x => x.ContentChanged);
+            ContentTemplateProperty.Changed.AddClassHandler<ContentPresenter>(x => x.ContentChanged);
             TemplatedParentProperty.Changed.AddClassHandler<ContentPresenter>(x => x.TemplatedParentChanged);
         }
 
@@ -313,27 +315,22 @@ namespace Avalonia.Controls.Presenters
 
             if (content != null && newChild == null)
             {
-                // We have content and it isn't a control, so first try to recycle the existing
-                // child control to display the new data by querying if the template that created
-                // the child can recycle items and that it also matches the new data.
-                if (oldChild != null &&
-                    _dataTemplate != null &&
-                    _dataTemplate.SupportsRecycling &&
-                    _dataTemplate.Match(content))
+                var dataTemplate = this.FindDataTemplate(content, ContentTemplate) ?? FuncDataTemplate.Default;
+
+                // We have content and it isn't a control, so if the new data template is the same
+                // as the old data template, try to recycle the existing child control to display
+                // the new data.
+                if (dataTemplate == _dataTemplate && dataTemplate.SupportsRecycling)
                 {
                     newChild = oldChild;
                 }
                 else
                 {
-                    // We couldn't recycle an existing control so find a data template for the data
-                    // and use it to create a control.
-                    _dataTemplate = this.FindDataTemplate(content, ContentTemplate) ?? FuncDataTemplate.Default;
+                    _dataTemplate = dataTemplate;
                     newChild = _dataTemplate.Build(content);
 
-                    // Try to give the new control its own name scope.
-                    var controlResult = newChild as Control;
-
-                    if (controlResult != null)
+                    // Give the new control its own name scope.
+                    if (newChild is Control controlResult)
                     {
                         NameScope.SetNameScope(controlResult, new NameScope());
                     }
@@ -424,6 +421,19 @@ namespace Avalonia.Controls.Presenters
         private void ContentChanged(AvaloniaPropertyChangedEventArgs e)
         {
             _createdChild = false;
+
+            if (((ILogical)this).IsAttachedToLogicalTree)
+            {
+                UpdateChild();
+            }
+            else if (Child != null)
+            {
+                VisualChildren.Remove(Child);
+                LogicalChildren.Remove(Child);
+                Child = null;
+                _dataTemplate = null;
+            }
+
             InvalidateMeasure();
         }
 

+ 11 - 0
src/Avalonia.Controls/Primitives/TemplatedControl.cs

@@ -285,6 +285,17 @@ namespace Avalonia.Controls.Primitives
             return this;
         }
 
+        /// <inheritdoc/>
+        protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
+        {
+            if (VisualChildren.Count > 0)
+            {
+                ((ILogical)VisualChildren[0]).NotifyAttachedToLogicalTree(e);
+            }
+
+            base.OnAttachedToLogicalTree(e);
+        }
+
         /// <inheritdoc/>
         protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
         {

+ 26 - 14
src/Avalonia.Controls/ToolTip.cs

@@ -105,16 +105,21 @@ namespace Avalonia.Controls
         {
             if (control != null && control.IsVisible && control.GetVisualRoot() != null)
             {
-                if (s_popup != null)
-                {
-                    throw new AvaloniaInternalException("Previous ToolTip not disposed.");
-                }
                 var cp = (control.GetVisualRoot() as IInputRoot)?.MouseDevice?.GetPosition(control);
                 var position = control.PointToScreen(cp ?? new Point(0, 0)) + new Vector(0, 22);
 
-                s_popup = new PopupRoot();
+                if (s_popup == null)
+                {
+                    s_popup = new PopupRoot();
+                    s_popup.Content = new ToolTip();
+                }
+                else
+                {
+                    ((ISetLogicalParent)s_popup).SetParent(null);
+                }
+
                 ((ISetLogicalParent)s_popup).SetParent(control);
-                s_popup.Content = new ToolTip { Content = GetTip(control) };
+                ((ToolTip)s_popup.Content).Content = GetTip(control);
                 s_popup.Position = position;
                 s_popup.Show();
 
@@ -146,16 +151,23 @@ namespace Avalonia.Controls
             {
                 if (s_popup != null)
                 {
-                    // Clear the ToolTip's Content in case it has control content: this will
-                    // reset its visual parent allowing it to be used again.
-                    ((ToolTip)s_popup.Content).Content = null;
-
-                    // Dispose of the popup.
-                    s_popup.Dispose();
-                    s_popup = null;
+                    DisposeTooltip();
+                    s_show.OnNext(null);
                 }
+            }
+        }
+
+        private static void DisposeTooltip()
+        {
+            if (s_popup != null)
+            {
+                // Clear the ToolTip's Content in case it has control content: this will
+                // reset its visual parent allowing it to be used again.
+                ((ToolTip)s_popup.Content).Content = null;
 
-                s_show.OnNext(null);
+                // Dispose of the popup.
+                s_popup.Dispose();
+                s_popup = null;
             }
         }
     }

+ 21 - 1
src/Avalonia.Controls/TreeView.cs

@@ -16,7 +16,7 @@ namespace Avalonia.Controls
     /// <summary>
     /// Displays a hierachical tree of data.
     /// </summary>
-    public class TreeView : ItemsControl
+    public class TreeView : ItemsControl, ICustomKeyboardNavigation
     {
         /// <summary>
         /// Defines the <see cref="AutoScrollToSelectedItem"/> property.
@@ -90,6 +90,26 @@ namespace Avalonia.Controls
             }
         }
 
+        (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction)
+        {
+            if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
+            {
+                if (!this.IsVisualAncestorOf(element))
+                {
+                    IControl result = _selectedItem != null ?
+                        ItemContainerGenerator.Index.ContainerFromItem(_selectedItem) :
+                        ItemContainerGenerator.ContainerFromIndex(0);
+                    return (true, result);
+                }
+                else
+                {
+                    return (true, null);
+                }
+            }
+
+            return (false, null);
+        }
+
         /// <inheritdoc/>
         protected override IItemContainerGenerator CreateItemContainerGenerator()
         {

+ 3 - 0
src/Avalonia.Input/Avalonia.Input.csproj

@@ -37,5 +37,8 @@
       <Link>Properties\SharedAssemblyInfo.cs</Link>
     </Compile>
   </ItemGroup>
+  <ItemGroup>
+    <PackageReference Include="System.ValueTuple" Version="4.3.1" />
+  </ItemGroup>
   <Import Project="..\..\build\Rx.props" />
 </Project>

+ 3 - 2
src/Avalonia.Input/FocusManager.cs

@@ -176,9 +176,10 @@ namespace Avalonia.Input
         /// <param name="e">The event args.</param>
         private void OnPreviewPointerPressed(object sender, RoutedEventArgs e)
         {
-            if (sender == e.Source)
+            var ev = (PointerPressedEventArgs)e;
+
+            if (sender == e.Source && ev.MouseButton == MouseButton.Left)
             {
-                var ev = (PointerPressedEventArgs)e;
                 var element = (ev.Device?.Captured as IInputElement) ?? (e.Source as IInputElement);
 
                 if (element == null || !CanFocus(element))

+ 15 - 0
src/Avalonia.Input/ICustomKeyboardNavigation.cs

@@ -0,0 +1,15 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+
+namespace Avalonia.Input
+{
+    /// <summary>
+    /// Designates a control as handling its own keyboard navigation.
+    /// </summary>
+    public interface ICustomKeyboardNavigation
+    {
+        (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction);
+    }
+}

+ 27 - 0
src/Avalonia.Input/KeyboardNavigationHandler.cs

@@ -2,7 +2,9 @@
 // Licensed under the MIT license. See licence.md file in the project root for full license information.
 
 using System;
+using System.Linq;
 using Avalonia.Input.Navigation;
+using Avalonia.VisualTree;
 
 namespace Avalonia.Input
 {
@@ -52,6 +54,31 @@ namespace Avalonia.Input
         {
             Contract.Requires<ArgumentNullException>(element != null);
 
+            var customHandler = element.GetSelfAndVisualAncestors()
+                .OfType<ICustomKeyboardNavigation>()
+                .FirstOrDefault();
+
+            if (customHandler != null)
+            {
+                var (handled, next) = customHandler.GetNext(element, direction);
+
+                if (handled)
+                {
+                    if (next != null)
+                    {
+                        return next;
+                    }
+                    else if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
+                    {
+                        return TabNavigation.GetNextInTabOrder((IInputElement)customHandler, direction, true);
+                    }
+                    else
+                    {
+                        return null;
+                    }
+                }
+            }
+
             if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
             {
                 return TabNavigation.GetNextInTabOrder(element, direction);

+ 14 - 2
src/Avalonia.Input/Navigation/DirectionalNavigation.cs

@@ -41,7 +41,7 @@ namespace Avalonia.Input.Navigation
                 {
                     case KeyboardNavigationMode.Continue:
                         return GetNextInContainer(element, container, direction) ??
-                               GetFirstInNextContainer(element, direction);
+                               GetFirstInNextContainer(element, element, direction);
                     case KeyboardNavigationMode.Cycle:
                         return GetNextInContainer(element, container, direction) ??
                                GetFocusableDescendant(container, direction);
@@ -173,10 +173,12 @@ namespace Avalonia.Input.Navigation
         /// <summary>
         /// Gets the first item that should be focused in the next container.
         /// </summary>
+        /// <param name="element">The element being navigated away from.</param>
         /// <param name="container">The container.</param>
         /// <param name="direction">The direction of the search.</param>
         /// <returns>The first element, or null if there are no more elements.</returns>
         private static IInputElement GetFirstInNextContainer(
+            IInputElement element,
             IInputElement container,
             NavigationDirection direction)
         {
@@ -200,6 +202,16 @@ namespace Avalonia.Input.Navigation
 
                 if (sibling != null)
                 {
+                    if (sibling is ICustomKeyboardNavigation custom)
+                    {
+                        var (handled, customNext) = custom.GetNext(element, direction);
+
+                        if (handled)
+                        {
+                            return customNext;
+                        }
+                    }
+
                     if (sibling.CanFocus())
                     {
                         next = sibling;
@@ -214,7 +226,7 @@ namespace Avalonia.Input.Navigation
 
                 if (next == null)
                 {
-                    next = GetFirstInNextContainer(parent, direction);
+                    next = GetFirstInNextContainer(element, parent, direction);
                 }
             }
             else

+ 62 - 25
src/Avalonia.Input/Navigation/TabNavigation.cs

@@ -18,13 +18,17 @@ namespace Avalonia.Input.Navigation
         /// </summary>
         /// <param name="element">The element.</param>
         /// <param name="direction">The tab direction. Must be Next or Previous.</param>
+        /// <param name="outsideElement">
+        /// If true will not descend into <paramref name="element"/> to find next control.
+        /// </param>
         /// <returns>
         /// The next element in the specified direction, or null if <paramref name="element"/>
         /// was the last in the requested direction.
         /// </returns>
         public static IInputElement GetNextInTabOrder(
             IInputElement element,
-            NavigationDirection direction)
+            NavigationDirection direction,
+            bool outsideElement = false)
         {
             Contract.Requires<ArgumentNullException>(element != null);
             Contract.Requires<ArgumentException>(
@@ -40,20 +44,20 @@ namespace Avalonia.Input.Navigation
                 switch (mode)
                 {
                     case KeyboardNavigationMode.Continue:
-                        return GetNextInContainer(element, container, direction) ??
-                               GetFirstInNextContainer(element, direction);
+                        return GetNextInContainer(element, container, direction, outsideElement) ??
+                               GetFirstInNextContainer(element, element, direction);
                     case KeyboardNavigationMode.Cycle:
-                        return GetNextInContainer(element, container, direction) ??
+                        return GetNextInContainer(element, container, direction, outsideElement) ??
                                GetFocusableDescendant(container, direction);
                     case KeyboardNavigationMode.Contained:
-                        return GetNextInContainer(element, container, direction);
+                        return GetNextInContainer(element, container, direction, outsideElement);
                     default:
-                        return GetFirstInNextContainer(container, direction);
+                        return GetFirstInNextContainer(element, container, direction);
                 }
             }
             else
             {
-                return GetFocusableDescendants(element).FirstOrDefault();
+                return GetFocusableDescendants(element, direction).FirstOrDefault();
             }
         }
 
@@ -66,16 +70,17 @@ namespace Avalonia.Input.Navigation
         private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction)
         {
             return direction == NavigationDirection.Next ?
-                GetFocusableDescendants(container).FirstOrDefault() :
-                GetFocusableDescendants(container).LastOrDefault();
+                GetFocusableDescendants(container, direction).FirstOrDefault() :
+                GetFocusableDescendants(container, direction).LastOrDefault();
         }
 
         /// <summary>
         /// Gets the focusable descendants of the specified element.
         /// </summary>
         /// <param name="element">The element.</param>
+        /// <param name="direction">The tab direction. Must be Next or Previous.</param>
         /// <returns>The element's focusable descendants.</returns>
-        private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element)
+        private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element, NavigationDirection direction)
         {
             var mode = KeyboardNavigation.GetTabNavigation((InputElement)element);
 
@@ -103,16 +108,25 @@ namespace Avalonia.Input.Navigation
 
             foreach (var child in children)
             {
-                if (child.CanFocus())
+                var customNext = GetCustomNext(child, direction);
+
+                if (customNext.handled)
                 {
-                    yield return child;
+                    yield return customNext.next;
                 }
-
-                if (child.CanFocusDescendants())
+                else
                 {
-                    foreach (var descendant in GetFocusableDescendants(child))
+                    if (child.CanFocus())
                     {
-                        yield return descendant;
+                        yield return child;
+                    }
+
+                    if (child.CanFocusDescendants())
+                    {
+                        foreach (var descendant in GetFocusableDescendants(child, direction))
+                        {
+                            yield return descendant;
+                        }
                     }
                 }
             }
@@ -124,15 +138,19 @@ namespace Avalonia.Input.Navigation
         /// <param name="element">The starting element/</param>
         /// <param name="container">The container.</param>
         /// <param name="direction">The direction.</param>
+        /// <param name="outsideElement">
+        /// If true will not descend into <paramref name="element"/> to find next control.
+        /// </param>
         /// <returns>The next element, or null if the element is the last.</returns>
         private static IInputElement GetNextInContainer(
             IInputElement element,
             IInputElement container,
-            NavigationDirection direction)
+            NavigationDirection direction,
+            bool outsideElement)
         {
-            if (direction == NavigationDirection.Next)
+            if (direction == NavigationDirection.Next && !outsideElement)
             {
-                var descendant = GetFocusableDescendants(element).FirstOrDefault();
+                var descendant = GetFocusableDescendants(element, direction).FirstOrDefault();
 
                 if (descendant != null)
                 {
@@ -167,7 +185,7 @@ namespace Avalonia.Input.Navigation
 
                 if (element != null && direction == NavigationDirection.Previous)
                 {
-                    var descendant = GetFocusableDescendants(element).LastOrDefault();
+                    var descendant = GetFocusableDescendants(element, direction).LastOrDefault();
 
                     if (descendant != null)
                     {
@@ -184,10 +202,12 @@ namespace Avalonia.Input.Navigation
         /// <summary>
         /// Gets the first item that should be focused in the next container.
         /// </summary>
+        /// <param name="element">The element being navigated away from.</param>
         /// <param name="container">The container.</param>
         /// <param name="direction">The direction of the search.</param>
         /// <returns>The first element, or null if there are no more elements.</returns>
         private static IInputElement GetFirstInNextContainer(
+            IInputElement element,
             IInputElement container,
             NavigationDirection direction)
         {
@@ -210,6 +230,13 @@ namespace Avalonia.Input.Navigation
 
                 if (sibling != null)
                 {
+                    var customNext = GetCustomNext(sibling, direction);
+
+                    if (customNext.handled)
+                    {
+                        return customNext.next;
+                    }
+
                     if (sibling.CanFocus())
                     {
                         next = sibling;
@@ -217,24 +244,34 @@ namespace Avalonia.Input.Navigation
                     else
                     {
                         next = direction == NavigationDirection.Next ?
-                            GetFocusableDescendants(sibling).FirstOrDefault() :
-                            GetFocusableDescendants(sibling).LastOrDefault();
+                            GetFocusableDescendants(sibling, direction).FirstOrDefault() :
+                            GetFocusableDescendants(sibling, direction).LastOrDefault();
                     }
                 }
 
                 if (next == null)
                 {
-                    next = GetFirstInNextContainer(parent, direction);
+                    next = GetFirstInNextContainer(element, parent, direction);
                 }
             }
             else
             {
                 next = direction == NavigationDirection.Next ?
-                    GetFocusableDescendants(container).FirstOrDefault() :
-                    GetFocusableDescendants(container).LastOrDefault();
+                    GetFocusableDescendants(container, direction).FirstOrDefault() :
+                    GetFocusableDescendants(container, direction).LastOrDefault();
             }
 
             return next;
         }
+
+        private static (bool handled, IInputElement next) GetCustomNext(IInputElement element, NavigationDirection direction)
+        {
+            if (element is ICustomKeyboardNavigation custom)
+            {
+                return custom.GetNext(element, direction);
+            }
+
+            return (false, null);
+        }
     }
 }

+ 64 - 33
src/Avalonia.Layout/LayoutManager.cs

@@ -14,8 +14,8 @@ namespace Avalonia.Layout
     /// </summary>
     public class LayoutManager : ILayoutManager
     {
-        private readonly HashSet<ILayoutable> _toMeasure = new HashSet<ILayoutable>();
-        private readonly HashSet<ILayoutable> _toArrange = new HashSet<ILayoutable>();
+        private readonly Queue<ILayoutable> _toMeasure = new Queue<ILayoutable>();
+        private readonly Queue<ILayoutable> _toArrange = new Queue<ILayoutable>();
         private bool _queued;
         private bool _running;
 
@@ -30,8 +30,18 @@ namespace Avalonia.Layout
             Contract.Requires<ArgumentNullException>(control != null);
             Dispatcher.UIThread.VerifyAccess();
 
-            _toMeasure.Add(control);
-            _toArrange.Add(control);
+            if (!control.IsAttachedToVisualTree)
+            {
+#if DEBUG
+                throw new AvaloniaInternalException(
+                    "LayoutManager.InvalidateMeasure called on a control that is detached from the visual tree.");
+#else
+                return;
+#endif
+            }
+
+            _toMeasure.Enqueue(control);
+            _toArrange.Enqueue(control);
             QueueLayoutPass();
         }
 
@@ -41,7 +51,17 @@ namespace Avalonia.Layout
             Contract.Requires<ArgumentNullException>(control != null);
             Dispatcher.UIThread.VerifyAccess();
 
-            _toArrange.Add(control);
+            if (!control.IsAttachedToVisualTree)
+            {
+#if DEBUG
+                throw new AvaloniaInternalException(
+                    "LayoutManager.InvalidateArrange called on a control that is detached from the visual tree.");
+#else
+                return;
+#endif
+            }
+
+            _toArrange.Enqueue(control);
             QueueLayoutPass();
         }
 
@@ -108,8 +128,12 @@ namespace Avalonia.Layout
         {
             while (_toMeasure.Count > 0)
             {
-                var next = _toMeasure.First();
-                Measure(next);
+                var control = _toMeasure.Dequeue();
+
+                if (!control.IsMeasureValid && control.IsAttachedToVisualTree)
+                {
+                    Measure(control);
+                }
             }
         }
 
@@ -117,53 +141,60 @@ namespace Avalonia.Layout
         {
             while (_toArrange.Count > 0 && _toMeasure.Count == 0)
             {
-                var next = _toArrange.First();
-                Arrange(next);
+                var control = _toArrange.Dequeue();
+
+                if (!control.IsArrangeValid && control.IsAttachedToVisualTree)
+                {
+                    Arrange(control);
+                }
             }
         }
 
         private void Measure(ILayoutable control)
         {
-            var root = control as ILayoutRoot;
-            var parent = control.VisualParent as ILayoutable;
-
-            if (root != null)
-            {
-                root.Measure(root.MaxClientSize);
-            }
-            else if (parent != null)
+            // Controls closest to the visual root need to be arranged first. We don't try to store
+            // ordered invalidation lists, instead we traverse the tree upwards, measuring the
+            // controls closest to the root first. This has been shown by benchmarks to be the
+            // fastest and most memory-efficent algorithm.
+            if (control.VisualParent is ILayoutable parent)
             {
                 Measure(parent);
             }
 
-            if (!control.IsMeasureValid)
+            // If the control being measured has IsMeasureValid == true here then its measure was
+            // handed by an ancestor and can be ignored. The measure may have also caused the
+            // control to be removed.
+            if (!control.IsMeasureValid && control.IsAttachedToVisualTree)
             {
-                control.Measure(control.PreviousMeasure.Value);
+                if (control is ILayoutRoot root)
+                {
+                    root.Measure(Size.Infinity);
+                }
+                else
+                {
+                    control.Measure(control.PreviousMeasure.Value);
+                }
             }
-
-            _toMeasure.Remove(control);
         }
 
         private void Arrange(ILayoutable control)
         {
-            var root = control as ILayoutRoot;
-            var parent = control.VisualParent as ILayoutable;
-
-            if (root != null)
-            {
-                root.Arrange(new Rect(root.ClientSize));
-            }
-            else if (parent != null)
+            if (control.VisualParent is ILayoutable parent)
             {
                 Arrange(parent);
             }
 
-            if (control.PreviousArrange.HasValue)
+            if (!control.IsArrangeValid && control.IsAttachedToVisualTree)
             {
-                control.Arrange(control.PreviousArrange.Value);
+                if (control is ILayoutRoot root)
+                {
+                    root.Arrange(new Rect(root.ClientSize));
+                }
+                else
+                {
+                    control.Arrange(control.PreviousArrange.Value);
+                }
             }
-
-            _toArrange.Remove(control);
         }
 
         private void QueueLayoutPass()

+ 12 - 4
src/Avalonia.Layout/Layoutable.cs

@@ -378,8 +378,12 @@ namespace Avalonia.Layout
 
                 IsMeasureValid = false;
                 IsArrangeValid = false;
-                LayoutManager.Instance?.InvalidateMeasure(this);
-                InvalidateVisual();
+
+                if (((ILayoutable)this).IsAttachedToVisualTree)
+                {
+                    LayoutManager.Instance?.InvalidateMeasure(this);
+                    InvalidateVisual();
+                }
             }
         }
 
@@ -393,8 +397,12 @@ namespace Avalonia.Layout
                 Logger.Verbose(LogArea.Layout, this, "Invalidated arrange");
 
                 IsArrangeValid = false;
-                LayoutManager.Instance?.InvalidateArrange(this);
-                InvalidateVisual();
+
+                if (((ILayoutable)this).IsAttachedToVisualTree)
+                {
+                    LayoutManager.Instance?.InvalidateArrange(this);
+                    InvalidateVisual();
+                }
             }
         }
 

+ 10 - 0
src/Avalonia.Styling/LogicalTree/ILogical.cs

@@ -36,6 +36,16 @@ namespace Avalonia.LogicalTree
         /// </summary>
         IAvaloniaReadOnlyList<ILogical> LogicalChildren { get; }
 
+        /// <summary>
+        /// Notifies the control that it is being attached to a rooted logical tree.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        /// <remarks>
+        /// This method will be called automatically by the framework, you should not need to call
+        /// this method yourself.
+        /// </remarks>
+        void NotifyAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e);
+
         /// <summary>
         /// Notifies the control that it is being detached from a rooted logical tree.
         /// </summary>

+ 2 - 2
src/Avalonia.Styling/Styling/Style.cs

@@ -61,12 +61,12 @@ namespace Avalonia.Styling
         }
 
         /// <summary>
-        /// Gets or sets style's selector.
+        /// Gets or sets the style's selector.
         /// </summary>
         public Selector Selector { get; set; }
 
         /// <summary>
-        /// Gets or sets style's setters.
+        /// Gets or sets the style's setters.
         /// </summary>
         [Content]
         public IEnumerable<ISetter> Setters { get; set; } = new List<ISetter>();

+ 1 - 1
src/Gtk/Avalonia.Gtk3/Interop/GObject.cs

@@ -41,7 +41,7 @@ namespace Avalonia.Gtk3.Interop
 
     class GtkWindow : GtkWidget
     {
-        
+        public static GtkWindow Null { get; } = new GtkWindow();
     }
 
     class GtkImContext : GObject

+ 2 - 0
src/Gtk/Avalonia.Gtk3/Interop/Utf8Buffer.cs

@@ -11,6 +11,8 @@ namespace Avalonia.Gtk3.Interop
             
         public Utf8Buffer(string s) : base(IntPtr.Zero, true)
         {
+            if (s == null)
+                return;
             _data = Encoding.UTF8.GetBytes(s);
             _gchandle = GCHandle.Alloc(_data, GCHandleType.Pinned);
             handle = _gchandle.AddrOfPinnedObject();

+ 2 - 1
src/Gtk/Avalonia.Gtk3/SystemDialogs.cs

@@ -18,7 +18,8 @@ namespace Avalonia.Gtk3
             bool multiselect, string initialFileName)
         {
             GtkFileChooser dlg;
-            using (var name = title != null ? new Utf8Buffer(title) : null)
+            parent = parent ?? GtkWindow.Null;
+            using (var name = new Utf8Buffer(title))
                 dlg = Native.GtkFileChooserDialogNew(name, parent, action, IntPtr.Zero);
             if (multiselect)
                 Native.GtkFileChooserSetSelectMultiple(dlg, true);

+ 1 - 1
src/Markup/Avalonia.Markup.Xaml/Data/MultiBinding.cs

@@ -102,7 +102,7 @@ namespace Avalonia.Markup.Xaml.Data
 
         private object ConvertValue(IList<object> values, Type targetType)
         {
-            var converted = Converter.Convert(values, targetType, null, CultureInfo.CurrentUICulture);
+            var converted = Converter.Convert(values, targetType, null, CultureInfo.CurrentCulture);
 
             if (converted == AvaloniaProperty.UnsetValue && FallbackValue != null)
             {

+ 2 - 2
src/Markup/Avalonia.Markup/Data/BindingExpression.cs

@@ -122,7 +122,7 @@ namespace Avalonia.Markup.Data
                         value,
                         type,
                         ConverterParameter,
-                        CultureInfo.CurrentUICulture);
+                        CultureInfo.CurrentCulture);
 
                     if (converted == AvaloniaProperty.UnsetValue)
                     {
@@ -186,7 +186,7 @@ namespace Avalonia.Markup.Data
                     value,
                     _targetType,
                     ConverterParameter,
-                    CultureInfo.CurrentUICulture);
+                    CultureInfo.CurrentCulture);
 
                 notification = converted as BindingNotification;
 

+ 1 - 0
tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj

@@ -1,6 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <Import Project="..\..\build\UnitTests.NetCore.targets" />
   <Import Project="..\..\build\Moq.props" />

+ 1 - 1
tests/Avalonia.Base.UnitTests/Properties/AssemblyInfo.cs

@@ -7,4 +7,4 @@ using Xunit;
 [assembly: AssemblyTitle("Avalonia.UnitTests")]
 
 // Don't run tests in parallel.
-[assembly: CollectionBehavior(DisableTestParallelization = true)]
+[assembly: CollectionBehavior(DisableTestParallelization = true)]

+ 2 - 1
tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj

@@ -49,6 +49,7 @@
     <Reference Include="System.Xml" />
   </ItemGroup>
   <ItemGroup>
+    <Compile Include="Layout\Measure.cs" />
     <Compile Include="Styling\ApplyStyling.cs" />
     <Compile Include="Program.cs" />
     <Compile Include="Properties\AssemblyInfo.cs" />
@@ -100,7 +101,7 @@
   </ItemGroup>
   <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
   <ItemGroup>
-    <PackageReference Include="BenchmarkDotNet" Version="0.9.2" />
+    <PackageReference Include="BenchmarkDotNet" Version="0.10.8" />
   </ItemGroup>
   <Import Project="$(MSBuildThisFileDirectory)..\..\src\Shared\nuget.workaround.targets" />
 </Project>

+ 65 - 0
tests/Avalonia.Benchmarks/Layout/Measure.cs

@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.UnitTests;
+using BenchmarkDotNet.Attributes;
+
+namespace Avalonia.Benchmarks.Layout
+{
+    [MemoryDiagnoser]
+    public class Measure : IDisposable
+    {
+        private IDisposable _app;
+        private TestRoot root;
+        private List<Control> controls = new List<Control>();
+
+        public Measure()
+        {
+            _app = UnitTestApplication.Start(TestServices.RealLayoutManager);
+
+            var panel = new StackPanel();
+            root = new TestRoot { Child = panel };
+            controls.Add(panel);
+            CreateChildren(panel, 3, 5);
+            LayoutManager.Instance.ExecuteInitialLayoutPass(root);
+        }
+
+        public void Dispose()
+        {
+            _app.Dispose();
+        }
+
+        [Benchmark]
+        public void Remeasure_Half()
+        {
+            var random = new Random(1);
+
+            foreach (var control in controls)
+            {
+                if (random.Next(2) == 0)
+                {
+                    control.InvalidateMeasure();
+                }
+            }
+
+            LayoutManager.Instance.ExecuteLayoutPass();
+        }
+
+        private void CreateChildren(IPanel parent, int childCount, int iterations)
+        {
+            for (var i = 0; i < childCount; ++i)
+            {
+                var control = new StackPanel();
+                parent.Children.Add(control);
+
+                if (iterations > 0)
+                {
+                    CreateChildren(control, childCount, iterations - 1);
+                }
+
+                controls.Add(control);
+            }
+        }
+    }
+}

+ 1 - 0
tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs

@@ -11,6 +11,7 @@ using Avalonia.VisualTree;
 
 namespace Avalonia.Benchmarks.Styling
 {
+    [MemoryDiagnoser]
     public class ApplyStyling : IDisposable
     {
         private IDisposable _app;

+ 4 - 0
tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj

@@ -1,12 +1,16 @@
 <Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <Import Project="..\..\build\UnitTests.NetCore.targets" />
   <Import Project="..\..\build\Moq.props" />
   <Import Project="..\..\build\XUnit.props" />
   <Import Project="..\..\build\Rx.props" />
   <Import Project="..\..\build\Microsoft.Reactive.Testing.props" />
+  <ItemGroup>
+    <PackageReference Include="System.ValueTuple" Version="4.3.1" />
+  </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\..\src\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" />
     <ProjectReference Include="..\..\src\Markup\Avalonia.Markup\Avalonia.Markup.csproj" />

+ 291 - 0
tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_InTemplate.cs

@@ -0,0 +1,291 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System.Linq;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Templates;
+using Avalonia.LogicalTree;
+using Avalonia.UnitTests;
+using Avalonia.VisualTree;
+using Moq;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests.Presenters
+{
+    /// <summary>
+    /// Tests for ContentControls that are hosted in a control template.
+    /// </summary>
+    public class ContentPresenterTests_InTemplate
+    {
+        [Fact]
+        public void Should_Register_With_Host_When_TemplatedParent_Set()
+        {
+            var host = new Mock<IContentPresenterHost>();
+            var target = new ContentPresenter();
+
+            target.SetValue(Control.TemplatedParentProperty, host.Object);
+
+            host.Verify(x => x.RegisterContentPresenter(target));
+        }
+
+        [Fact]
+        public void Setting_Content_To_Control_Should_Set_Child()
+        {
+            var (target, _) = CreateTarget();
+            var child = new Border();
+
+            target.Content = child;
+
+            Assert.Equal(child, target.Child);
+        }
+
+        [Fact]
+        public void Setting_Content_To_Control_Should_Update_Logical_Tree()
+        {
+            var (target, parent) = CreateTarget();
+            var child = new Border();
+
+            target.Content = child;
+
+            Assert.Equal(parent, child.GetLogicalParent());
+            Assert.Equal(new[] { child }, parent.GetLogicalChildren());
+        }
+
+        [Fact]
+        public void Setting_Content_To_Control_Should_Update_Visual_Tree()
+        {
+            var (target, _) = CreateTarget();
+            var child = new Border();
+
+            target.Content = child;
+
+            Assert.Equal(target, child.GetVisualParent());
+            Assert.Equal(new[] { child }, target.GetVisualChildren());
+        }
+
+        [Fact]
+        public void Setting_Content_To_String_Should_Create_TextBlock()
+        {
+            var (target, _) = CreateTarget();
+
+            target.Content = "Foo";
+
+            Assert.IsType<TextBlock>(target.Child);
+            Assert.Equal("Foo", ((TextBlock)target.Child).Text);
+        }
+
+        [Fact]
+        public void Setting_Content_To_String_Should_Update_Logical_Tree()
+        {
+            var (target, parent) = CreateTarget();
+
+            target.Content = "Foo";
+
+            var child = target.Child;
+            Assert.Equal(parent, child.GetLogicalParent());
+            Assert.Equal(new[] { child }, parent.GetLogicalChildren());
+        }
+
+        [Fact]
+        public void Setting_Content_To_String_Should_Update_Visual_Tree()
+        {
+            var (target, _) = CreateTarget();
+
+            target.Content = "Foo";
+
+            var child = target.Child;
+            Assert.Equal(target, child.GetVisualParent());
+            Assert.Equal(new[] { child }, target.GetVisualChildren());
+        }
+
+        [Fact]
+        public void Clearing_Control_Content_Should_Update_Logical_Tree()
+        {
+            var (target, _) = CreateTarget();
+            var child = new Border();
+
+            target.Content = child;
+            target.Content = null;
+
+            Assert.Equal(null, child.GetLogicalParent());
+            Assert.Empty(target.GetLogicalChildren());
+        }
+
+        [Fact]
+        public void Clearing_Control_Content_Should_Update_Visual_Tree()
+        {
+            var (target, _) = CreateTarget();
+            var child = new Border();
+
+            target.Content = child;
+            target.Content = null;
+
+            Assert.Equal(null, child.GetVisualParent());
+            Assert.Empty(target.GetVisualChildren());
+        }
+
+        [Fact]
+        public void Control_Content_Should_Not_Be_NameScope()
+        {
+            var (target, _) = CreateTarget();
+
+            target.Content = new TextBlock();
+
+            Assert.IsType<TextBlock>(target.Child);
+            Assert.Null(NameScope.GetNameScope((Control)target.Child));
+        }
+
+        [Fact]
+        public void DataTemplate_Created_Control_Should_Be_NameScope()
+        {
+            var (target, _) = CreateTarget();
+
+            target.Content = "Foo";
+
+            Assert.IsType<TextBlock>(target.Child);
+            Assert.NotNull(NameScope.GetNameScope((Control)target.Child));
+        }
+
+        [Fact]
+        public void Assigning_Control_To_Content_Should_Not_Set_DataContext()
+        {
+            var (target, _) = CreateTarget();
+            target.Content = new Border();
+
+            Assert.False(target.IsSet(Control.DataContextProperty));
+        }
+
+        [Fact]
+        public void Assigning_NonControl_To_Content_Should_Set_DataContext_On_UpdateChild()
+        {
+            var (target, _) = CreateTarget();
+            target.Content = "foo";
+
+            Assert.Equal("foo", target.DataContext);
+        }
+
+        [Fact]
+        public void Should_Use_ContentTemplate_If_Specified()
+        {
+            var (target, _) = CreateTarget();
+
+            target.ContentTemplate = new FuncDataTemplate<string>(_ => new Canvas());
+            target.Content = "Foo";
+
+            Assert.IsType<Canvas>(target.Child);
+        }
+
+        [Fact]
+        public void Should_Update_If_ContentTemplate_Changed()
+        {
+            var (target, _) = CreateTarget();
+
+            target.Content = "Foo";
+            Assert.IsType<TextBlock>(target.Child);
+
+            target.ContentTemplate = new FuncDataTemplate<string>(_ => new Canvas());
+            Assert.IsType<Canvas>(target.Child);
+
+            target.ContentTemplate = null;
+            Assert.IsType<TextBlock>(target.Child);
+        }
+
+        [Fact]
+        public void Assigning_Control_To_Content_After_NonControl_Should_Clear_DataContext()
+        {
+            var (target, _) = CreateTarget();
+
+            target.Content = "foo";
+
+            Assert.True(target.IsSet(Control.DataContextProperty));
+
+            target.Content = new Border();
+
+            Assert.False(target.IsSet(Control.DataContextProperty));
+        }
+
+        [Fact]
+        public void Recycles_DataTemplate()
+        {
+            var (target, _) = CreateTarget();
+            target.DataTemplates.Add(new FuncDataTemplate<string>(_ => new Border(), true));
+
+            target.Content = "foo";
+
+            var control = target.Child;
+            Assert.IsType<Border>(control);
+
+            target.Content = "bar";
+            Assert.Same(control, target.Child);
+        }
+
+        [Fact]
+        public void Detects_DataTemplate_Doesnt_Match_And_Doesnt_Recycle()
+        {
+            var (target, _) = CreateTarget();
+            target.DataTemplates.Add(new FuncDataTemplate<string>(x => x == "foo", _ => new Border(), true));
+
+            target.Content = "foo";
+
+            var control = target.Child;
+            Assert.IsType<Border>(control);
+
+            target.Content = "bar";
+            Assert.IsType<TextBlock>(target.Child);
+        }
+
+        [Fact]
+        public void Detects_DataTemplate_Doesnt_Support_Recycling()
+        {
+            var (target, _) = CreateTarget();
+            target.DataTemplates.Add(new FuncDataTemplate<string>(_ => new Border(), false));
+
+            target.Content = "foo";
+
+            var control = target.Child;
+            Assert.IsType<Border>(control);
+
+            target.Content = "bar";
+            Assert.NotSame(control, target.Child);
+        }
+
+        [Fact]
+        public void Reevaluates_DataTemplates_When_Recycling()
+        {
+            var (target, _) = CreateTarget();
+
+            target.DataTemplates.Add(new FuncDataTemplate<string>(x => x == "bar", _ => new Canvas(), true));
+            target.DataTemplates.Add(new FuncDataTemplate<string>(_ => new Border(), true));
+
+            target.Content = "foo";
+
+            var control = target.Child;
+            Assert.IsType<Border>(control);
+
+            target.Content = "bar";
+            Assert.IsType<Canvas>(target.Child);
+        }
+
+        (ContentPresenter presenter, ContentControl templatedParent) CreateTarget()
+        {
+            var templatedParent = new ContentControl
+            {
+                Template = new FuncControlTemplate<ContentControl>(x => 
+                    new ContentPresenter
+                    {
+                        Name = "PART_ContentPresenter",
+                    }),
+            };
+            var root = new TestRoot { Child = templatedParent };
+
+            templatedParent.ApplyTemplate();
+
+            return ((ContentPresenter)templatedParent.Presenter, templatedParent);
+        }
+
+        private class TestContentControl : ContentControl
+        {
+            public IControl Child { get; set; }
+        }
+    }
+}

+ 11 - 179
tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests.cs → tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Standalone.cs

@@ -15,91 +15,13 @@ using Xunit;
 
 namespace Avalonia.Controls.UnitTests.Presenters
 {
-    public class ContentPresenterTests
+    /// <summary>
+    /// Tests for ContentControls that aren't hosted in a control template.
+    /// </summary>
+    public class ContentPresenterTests_Standalone
     {
         [Fact]
-        public void Should_Register_With_Host_When_TemplatedParent_Set()
-        {
-            var host = new Mock<IContentPresenterHost>();
-            var target = new ContentPresenter();
-
-            target.SetValue(Control.TemplatedParentProperty, host.Object);
-
-            host.Verify(x => x.RegisterContentPresenter(target));
-        }
-
-        [Fact]
-        public void Setting_Content_To_Control_Should_Set_Child()
-        {
-            var target = new ContentPresenter();
-            var child = new Border();
-
-            target.Content = child;
-
-            Assert.Null(target.Child);
-            target.UpdateChild();
-            Assert.Equal(child, target.Child);
-        }
-
-        [Fact]
-        public void Setting_Content_To_String_Should_Create_TextBlock()
-        {
-            var target = new ContentPresenter();
-
-            target.Content = "Foo";
-
-            Assert.Null(target.Child);
-            target.UpdateChild();
-            Assert.IsType<TextBlock>(target.Child);
-            Assert.Equal("Foo", ((TextBlock)target.Child).Text);
-        }
-
-        [Fact]
-        public void Control_Content_Should_Not_Be_NameScope()
-        {
-            var target = new ContentPresenter();
-
-            target.Content = new TextBlock();
-
-            Assert.Null(target.Child);
-            target.UpdateChild();
-            Assert.IsType<TextBlock>(target.Child);
-            Assert.Null(NameScope.GetNameScope((Control)target.Child));
-        }
-
-        [Fact]
-        public void DataTemplate_Created_Control_Should_Be_NameScope()
-        {
-            var target = new ContentPresenter();
-
-            target.Content = "Foo";
-
-            Assert.Null(target.Child);
-            target.UpdateChild();
-            Assert.IsType<TextBlock>(target.Child);
-            Assert.NotNull(NameScope.GetNameScope((Control)target.Child));
-        }
-
-        [Fact]
-        public void Should_Set_Childs_Parent_To_TemplatedParent()
-        {
-            var content = new Border();
-            var target = new TestContentControl
-            {
-                Template = new FuncControlTemplate<TestContentControl>(parent =>
-                    new ContentPresenter { Content = parent.Child }),
-                Child = content,
-            };
-
-            target.ApplyTemplate();
-            var presenter = ((ContentPresenter)target.GetVisualChildren().Single());
-            presenter.UpdateChild();
-
-            Assert.Same(target, content.Parent);
-        }
-
-        [Fact]
-        public void Should_Set_Childs_Parent_To_Itself_Outside_Template()
+        public void Should_Set_Childs_Parent_To_Itself_Standalone()
         {
             var content = new Border();
             var target = new ContentPresenter { Content = content };
@@ -110,7 +32,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
         }
 
         [Fact]
-        public void Should_Add_Child_To_Own_LogicalChildren_Outside_Template()
+        public void Should_Add_Child_To_Own_LogicalChildren_Standalone()
         {
             var content = new Border();
             var target = new ContentPresenter { Content = content };
@@ -124,94 +46,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
         }
 
         [Fact]
-        public void Adding_To_Logical_Tree_Should_Reevaluate_DataTemplates()
-        {
-            var target = new ContentPresenter
-            {
-                Content = "Foo",
-            };
-
-            target.UpdateChild();
-            Assert.IsType<TextBlock>(target.Child);
-
-            var root = new TestRoot
-            {
-                DataTemplates = new DataTemplates
-                {
-                    new FuncDataTemplate<string>(x => new Decorator()),
-                },
-            };
-
-            root.Child = target;
-            target.ApplyTemplate();
-            Assert.IsType<Decorator>(target.Child);
-        }
-
-        [Fact]
-        public void Assigning_Control_To_Content_Should_Not_Set_DataContext()
-        {
-            var target = new ContentPresenter
-            {
-                Content = new Border(),
-            };
-
-            Assert.False(target.IsSet(Control.DataContextProperty));
-        }
-
-        [Fact]
-        public void Assigning_NonControl_To_Content_Should_Set_DataContext_On_UpdateChild()
-        {
-            var target = new ContentPresenter
-            {
-                Content = "foo",
-            };
-
-            target.UpdateChild();
-
-            Assert.Equal("foo", target.DataContext);
-        }
-
-        [Fact]
-        public void Assigning_Control_To_Content_After_NonControl_Should_Clear_DataContext()
-        {
-            var target = new ContentPresenter();
-
-            target.Content = "foo";
-            target.UpdateChild();
-
-            Assert.True(target.IsSet(Control.DataContextProperty));
-
-            target.Content = new Border();
-            target.UpdateChild();
-
-            Assert.False(target.IsSet(Control.DataContextProperty));
-        }
-
-        [Fact]
-        public void Tries_To_Recycle_DataTemplate()
-        {
-            var target = new ContentPresenter
-            {
-                DataTemplates = new DataTemplates
-                {
-                    new FuncDataTemplate<string>(_ => new Border(), true),
-                },
-                Content = "foo",
-            };
-
-            target.UpdateChild();
-            var control = target.Child;
-
-            Assert.IsType<Border>(control);
-
-            target.Content = "bar";
-            target.UpdateChild();
-
-            Assert.Same(control, target.Child);
-        }
-
-        [Fact]
-        public void Should_Raise_DetachedFromLogicalTree_On_Content_Changed_OutsideTemplate()
+        public void Should_Raise_DetachedFromLogicalTree_On_Content_Changed_Standalone()
         {
             var target = new ContentPresenter
             {
@@ -250,7 +85,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
         }
 
         [Fact]
-        public void Should_Raise_DetachedFromLogicalTree_In_ContentControl_On_Content_Changed_OutsideTemplate()
+        public void Should_Raise_DetachedFromLogicalTree_In_ContentControl_On_Content_Changed_Standalone()
         {
             var contentControl = new ContentControl
             {
@@ -292,13 +127,14 @@ namespace Avalonia.Controls.UnitTests.Presenters
             var tbbar = target.Child as ContentControl;
 
             Assert.NotNull(tbbar);
+
             Assert.True(tbbar != tbfoo);
             Assert.False((tbfoo as IControl).IsAttachedToLogicalTree);
             Assert.True(foodetached);
         }
 
         [Fact]
-        public void Should_Raise_DetachedFromLogicalTree_On_Detached_OutsideTemplate()
+        public void Should_Raise_DetachedFromLogicalTree_On_Detached_Standalone()
         {
             var target = new ContentPresenter
             {
@@ -332,7 +168,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
         }
 
         [Fact]
-        public void Should_Remove_Old_Child_From_LogicalChildren_On_ContentChanged_OutsideTemplate()
+        public void Should_Remove_Old_Child_From_LogicalChildren_On_ContentChanged_Standalone()
         {
             var target = new ContentPresenter
             {
@@ -363,9 +199,5 @@ namespace Avalonia.Controls.UnitTests.Presenters
             Assert.NotEqual(foo, logicalChildren.First());
         }
 
-        private class TestContentControl : ContentControl
-        {
-            public IControl Child { get; set; }
-        }
     }
 }

+ 102 - 0
tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Unrooted.cs

@@ -0,0 +1,102 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Templates;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests.Presenters
+{
+    /// <summary>
+    /// Tests for ContentControls that are not attached to a logical tree.
+    /// </summary>
+    public class ContentPresenterTests_Unrooted
+    {
+        [Fact]
+        public void Setting_Content_To_Control_Should_Not_Set_Child_Unless_UpdateChild_Called()
+        {
+            var target = new ContentPresenter();
+            var child = new Border();
+
+            target.Content = child;
+            Assert.Null(target.Child);
+
+            target.ApplyTemplate();
+            Assert.Null(target.Child);
+
+            target.UpdateChild();
+            Assert.Equal(child, target.Child);
+        }
+
+        [Fact]
+        public void Setting_Content_To_String_Should_Not_Create_TextBlock_Unless_UpdateChild_Called()
+        {
+            var target = new ContentPresenter();
+
+            target.Content = "Foo";
+            Assert.Null(target.Child);
+
+            target.ApplyTemplate();
+            Assert.Null(target.Child);
+
+            target.UpdateChild();
+            Assert.IsType<TextBlock>(target.Child);
+            Assert.Equal("Foo", ((TextBlock)target.Child).Text);
+        }
+
+        [Fact]
+        public void Clearing_Control_Content_Should_Remove_Child_Immediately()
+        {
+            var target = new ContentPresenter();
+            var child = new Border();
+
+            target.Content = child;
+            target.UpdateChild();
+            Assert.Equal(child, target.Child);
+
+            target.Content = null;
+            Assert.Null(target.Child);
+        }
+
+        [Fact]
+        public void Clearing_String_Content_Should_Remove_Child_Immediately()
+        {
+            var target = new ContentPresenter();
+
+            target.Content = "Foo";
+            target.UpdateChild();
+            Assert.IsType<TextBlock>(target.Child);
+
+            target.Content = null;
+            Assert.Null(target.Child);
+        }
+
+        [Fact]
+        public void Adding_To_Logical_Tree_Should_Reevaluate_DataTemplates()
+        {
+            var root = new TestRoot();
+            var target = new ContentPresenter();
+
+            target.Content = "Foo";
+            Assert.Null(target.Child);
+
+            root.Child = target;
+            target.ApplyTemplate();
+            Assert.IsType<TextBlock>(target.Child);
+
+            root.Child = null;
+            root = new TestRoot
+            {
+                DataTemplates = new DataTemplates
+                {
+                    new FuncDataTemplate<string>(x => new Decorator()),
+                },
+            };
+
+            root.Child = target;
+            target.ApplyTemplate();
+            Assert.IsType<Decorator>(target.Child);
+        }
+    }
+}

+ 109 - 0
tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs

@@ -0,0 +1,109 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.LogicalTree;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests.Primitives
+{
+    public class PopupRootTests
+    {
+        [Fact]
+        public void PopupRoot_IsAttachedToLogicalTree_Is_True()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var target = CreateTarget();
+
+                Assert.True(((ILogical)target).IsAttachedToLogicalTree);
+            }
+        }
+
+        [Fact]
+        public void Templated_Child_IsAttachedToLogicalTree_Is_True()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var target = CreateTarget();
+
+                Assert.True(target.Presenter.IsAttachedToLogicalTree);
+            }
+        }
+
+        [Fact]
+        public void Attaching_PopupRoot_To_Parent_Logical_Tree_Raises_DetachedFromLogicalTree_And_AttachedToLogicalTree()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var child = new Decorator();
+                var target = CreateTarget();
+                var window = new Window();
+                var detachedCount = 0;
+                var attachedCount = 0;
+
+                target.Content = child;
+
+                target.DetachedFromLogicalTree += (s, e) => ++detachedCount;
+                child.DetachedFromLogicalTree += (s, e) => ++detachedCount;
+                target.AttachedToLogicalTree += (s, e) => ++attachedCount;
+                child.AttachedToLogicalTree += (s, e) => ++attachedCount;
+
+                ((ISetLogicalParent)target).SetParent(window);
+
+                Assert.Equal(2, detachedCount);
+                Assert.Equal(2, attachedCount);
+            }
+        }
+
+        [Fact]
+        public void Detaching_PopupRoot_From_Parent_Logical_Tree_Raises_DetachedFromLogicalTree_And_AttachedToLogicalTree()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var child = new Decorator();
+                var target = CreateTarget();
+                var window = new Window();
+                var detachedCount = 0;
+                var attachedCount = 0;
+
+                target.Content = child;
+                ((ISetLogicalParent)target).SetParent(window);
+
+                target.DetachedFromLogicalTree += (s, e) => ++detachedCount;
+                child.DetachedFromLogicalTree += (s, e) => ++detachedCount;
+                target.AttachedToLogicalTree += (s, e) => ++attachedCount;
+                child.AttachedToLogicalTree += (s, e) => ++attachedCount;
+
+                ((ISetLogicalParent)target).SetParent(null);
+
+                // Despite being detached from the parent logical tree, we're still attached to a
+                // logical tree as PopupRoot itself is a logical tree root.
+                Assert.True(((ILogical)target).IsAttachedToLogicalTree);
+                Assert.True(((ILogical)child).IsAttachedToLogicalTree);
+                Assert.Equal(2, detachedCount);
+                Assert.Equal(2, attachedCount);
+            }
+        }
+
+        private PopupRoot CreateTarget()
+        {
+            var result = new PopupRoot
+            {
+                Template = new FuncControlTemplate<PopupRoot>(_ =>
+                    new ContentPresenter
+                    {
+                        Name = "PART_ContentPresenter",
+                    }),
+            };
+
+            result.ApplyTemplate();
+
+            return result;
+        }
+    }
+}

+ 28 - 0
tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests.cs

@@ -527,6 +527,34 @@ namespace Avalonia.Controls.UnitTests.Primitives
             }
         }
 
+        [Fact]
+        public void Moving_To_New_LogicalTree_Should_Detach_Attach_Template_Child()
+        {
+            using (UnitTestApplication.Start(TestServices.RealStyler))
+            {
+                TestTemplatedControl target;
+                var root = new TestRoot
+                {
+                    Child = target = new TestTemplatedControl
+                    {
+                        Template = new FuncControlTemplate(_ => new Decorator()),
+                    }
+                };
+
+                Assert.NotNull(target.Template);
+                target.ApplyTemplate();
+
+                var templateChild = (ILogical)target.GetVisualChildren().Single();
+                Assert.True(templateChild.IsAttachedToLogicalTree);
+
+                root.Child = null;
+                Assert.False(templateChild.IsAttachedToLogicalTree);
+
+                var newRoot = new TestRoot { Child = target };
+                Assert.True(templateChild.IsAttachedToLogicalTree);
+            }
+        }
+
         private static IControl ScrollingContentControlTemplate(ContentControl control)
         {
             return new Border

+ 14 - 5
tests/Avalonia.Controls.UnitTests/TopLevelTests.cs

@@ -2,24 +2,33 @@
 // Licensed under the MIT license. See licence.md file in the project root for full license information.
 
 using System;
-using System.Reactive;
-using System.Reactive.Subjects;
-using Moq;
 using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Templates;
 using Avalonia.Input;
 using Avalonia.Input.Raw;
 using Avalonia.Layout;
+using Avalonia.LogicalTree;
 using Avalonia.Platform;
-using Avalonia.Rendering;
-using Avalonia.Styling;
 using Avalonia.UnitTests;
+using Moq;
 using Xunit;
 
 namespace Avalonia.Controls.UnitTests
 {
     public class TopLevelTests
     {
+        [Fact]
+        public void IsAttachedToLogicalTree_Is_True()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var impl = new Mock<ITopLevelImpl>();
+                var target = new TestTopLevel(impl.Object);
+
+                Assert.True(((ILogical)target).IsAttachedToLogicalTree);
+            }
+        }
+
         [Fact]
         public void ClientSize_Should_Be_Set_On_Construction()
         {

+ 44 - 0
tests/Avalonia.Controls.UnitTests/TreeViewTests.cs

@@ -315,6 +315,50 @@ namespace Avalonia.Controls.UnitTests
             Assert.Equal(new[] { "NewChild1" }, ExtractItemHeader(target, 1));
         }
 
+        [Fact]
+        public void Keyboard_Navigation_Should_Move_To_Last_Selected_Node()
+        {
+            using (UnitTestApplication.Start(TestServices.RealFocus))
+            {
+                var focus = FocusManager.Instance;
+                var navigation = AvaloniaLocator.Current.GetService<IKeyboardNavigationHandler>();
+                var data = CreateTestTreeData();
+
+                var target = new TreeView
+                {
+                    Template = CreateTreeViewTemplate(),
+                    Items = data,
+                    DataTemplates = CreateNodeDataTemplate(),
+                };
+
+                var button = new Button();
+
+                var root = new TestRoot
+                {
+                    Child = new StackPanel
+                    {
+                        Children = { target, button },
+                    }
+                };
+
+                ApplyTemplates(target);
+
+                var item = data[0].Children[0];
+                var node = target.ItemContainerGenerator.Index.ContainerFromItem(item);
+                Assert.NotNull(node);
+
+                target.SelectedItem = item;
+                node.Focus();
+                Assert.Same(node, focus.Current);
+
+                navigation.Move(focus.Current, NavigationDirection.Next);
+                Assert.Same(button, focus.Current);
+
+                navigation.Move(focus.Current, NavigationDirection.Next);
+                Assert.Same(node, focus.Current);
+            }
+        }
+
         private void ApplyTemplates(TreeView tree)
         {
             tree.ApplyTemplate();

+ 1 - 0
tests/Avalonia.Input.UnitTests/Avalonia.Input.UnitTests.csproj

@@ -1,6 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <Import Project="..\..\build\UnitTests.NetCore.targets" />
   <Import Project="..\..\build\Moq.props" />

+ 214 - 0
tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs

@@ -0,0 +1,214 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using Avalonia.Controls;
+using Xunit;
+
+namespace Avalonia.Input.UnitTests
+{
+    public class KeyboardNavigationTests_Custom
+    {
+        [Fact]
+        public void Tab_Should_Custom_Navigate_Within_Children()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    (current = new Button { Content = "Button 1" }),
+                    new Button { Content = "Button 2" },
+                    (next = new Button { Content = "Button 3" }),
+                },
+                NextControl = next,
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void Right_Should_Custom_Navigate_Within_Children()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    (current = new Button { Content = "Button 1" }),
+                    new Button { Content = "Button 2" },
+                    (next = new Button { Content = "Button 3" }),
+                },
+                NextControl = next,
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void Tab_Should_Custom_Navigate_From_Outside()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    new Button { Content = "Button 1" },
+                    new Button { Content = "Button 2" },
+                    (next = new Button { Content = "Button 3" }),
+                },
+                NextControl = next,
+            };
+
+            var root = new StackPanel
+            {
+                Children =
+                {
+                    (current = new Button { Content = "Outside" }),
+                    target,
+                }
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void Tab_Should_Custom_Navigate_From_Outside_When_Wrapping()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    new Button { Content = "Button 1" },
+                    new Button { Content = "Button 2" },
+                    (next = new Button { Content = "Button 3" }),
+                },
+                NextControl = next,
+            };
+
+            var root = new StackPanel
+            {
+                Children =
+                {
+                    target,
+                    (current = new Button { Content = "Outside" }),
+                }
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void ShiftTab_Should_Custom_Navigate_From_Outside()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    new Button { Content = "Button 1" },
+                    new Button { Content = "Button 2" },
+                    (next = new Button { Content = "Button 3" }),
+                },
+                NextControl = next,
+            };
+
+            var root = new StackPanel
+            {
+                Children =
+                {
+                    (current = new Button { Content = "Outside" }),
+                    target,
+                }
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void Right_Should_Custom_Navigate_From_Outside()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    new Button { Content = "Button 1" },
+                    new Button { Content = "Button 2" },
+                    (next = new Button { Content = "Button 3" }),
+                },
+                NextControl = next,
+            };
+
+            var root = new StackPanel
+            {
+                Children =
+                {
+                    (current = new Button { Content = "Outside" }),
+                    target,
+                },
+                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    new Button { Content = "Button 1" },
+                    (current = new Button { Content = "Button 2" }),
+                    new Button { Content = "Button 3" },
+                },
+            };
+
+            var root = new StackPanel
+            {
+                Children =
+                {
+                    target,
+                    (next = new Button { Content = "Outside" }),
+                }
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
+
+            Assert.Same(next, result);
+        }
+
+        private class CustomNavigatingStackPanel : StackPanel, ICustomKeyboardNavigation
+        {
+            public bool CustomNavigates { get; set; } = true;
+            public IInputElement NextControl { get; set; }
+
+            public (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction)
+            {
+                return (CustomNavigates, NextControl);
+            }
+        }
+    }
+}

+ 1 - 0
tests/Avalonia.Interactivity.UnitTests/Avalonia.Interactivity.UnitTests.csproj

@@ -1,6 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <Import Project="..\..\build\UnitTests.NetCore.targets" />
   <Import Project="..\..\build\XUnit.props" />

+ 1 - 0
tests/Avalonia.Layout.UnitTests/Avalonia.Layout.UnitTests.csproj

@@ -1,6 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <Import Project="..\..\build\UnitTests.NetCore.targets" />
   <Import Project="..\..\build\Moq.props" />

+ 265 - 7
tests/Avalonia.Layout.UnitTests/LayoutManagerTests.cs

@@ -2,25 +2,276 @@
 // Licensed under the MIT license. See licence.md file in the project root for full license information.
 
 using Avalonia.Controls;
+using Avalonia.UnitTests;
+using System;
 using Xunit;
+using System.Collections.Generic;
 
 namespace Avalonia.Layout.UnitTests
 {
     public class LayoutManagerTests
     {
         [Fact]
-        public void Invalidating_Child_Should_Remeasure_Parent()
+        public void Measures_And_Arranges_InvalidateMeasured_Control()
         {
-            var layoutManager = new LayoutManager();
+            var target = new LayoutManager();
 
-            using (AvaloniaLocator.EnterScope())
+            using (Start(target))
             {
-                AvaloniaLocator.CurrentMutable.Bind<ILayoutManager>().ToConstant(layoutManager);
+                var control = new LayoutTestControl();
+                var root = new LayoutTestRoot { Child = control };
+
+                target.ExecuteInitialLayoutPass(root);
+                control.Measured = control.Arranged = false;
+
+                control.InvalidateMeasure();
+                target.ExecuteLayoutPass();
+
+                Assert.True(control.Measured);
+                Assert.True(control.Arranged);
+            }
+        }
+
+        [Fact]
+        public void Arranges_InvalidateArranged_Control()
+        {
+            var target = new LayoutManager();
+
+            using (Start(target))
+            {
+                var control = new LayoutTestControl();
+                var root = new LayoutTestRoot { Child = control };
+
+                target.ExecuteInitialLayoutPass(root);
+                control.Measured = control.Arranged = false;
+
+                control.InvalidateArrange();
+                target.ExecuteLayoutPass();
+
+                Assert.False(control.Measured);
+                Assert.True(control.Arranged);
+            }
+        }
+
+        [Fact]
+        public void Measures_Parent_Of_Newly_Added_Control()
+        {
+            var target = new LayoutManager();
+
+            using (Start(target))
+            {
+                var control = new LayoutTestControl();
+                var root = new LayoutTestRoot();
+
+                target.ExecuteInitialLayoutPass(root);
+                root.Child = control;
+                root.Measured = root.Arranged = false;
+
+                target.ExecuteLayoutPass();
+
+                Assert.True(root.Measured);
+                Assert.True(root.Arranged);
+                Assert.True(control.Measured);
+                Assert.True(control.Arranged);
+            }
+        }
+
+        [Fact]
+        public void Measures_In_Correct_Order()
+        {
+            var target = new LayoutManager();
+
+            using (Start(target))
+            {
+                LayoutTestControl control1;
+                LayoutTestControl control2;
+                var root = new LayoutTestRoot
+                {
+                    Child = control1 = new LayoutTestControl
+                    {
+                        Child = control2 = new LayoutTestControl(),
+                    }
+                };
+
+
+                var order = new List<ILayoutable>();
+                Size MeasureOverride(ILayoutable control, Size size)
+                {
+                    order.Add(control);
+                    return new Size(10, 10);
+                }
+
+                root.DoMeasureOverride = MeasureOverride;
+                control1.DoMeasureOverride = MeasureOverride;
+                control2.DoMeasureOverride = MeasureOverride;
+                target.ExecuteInitialLayoutPass(root);
+
+                control2.InvalidateMeasure();
+                control1.InvalidateMeasure();
+                root.InvalidateMeasure();
+
+                order.Clear();
+                target.ExecuteLayoutPass();
+
+                Assert.Equal(new ILayoutable[] { root, control1, control2 }, order);
+            }
+        }
+
+        [Fact]
+        public void Measures_Root_And_Grandparent_In_Correct_Order()
+        {
+            var target = new LayoutManager();
+
+            using (Start(target))
+            {
+                LayoutTestControl control1;
+                LayoutTestControl control2;
+                var root = new LayoutTestRoot
+                {
+                    Child = control1 = new LayoutTestControl
+                    {
+                        Child = control2 = new LayoutTestControl(),
+                    }
+                };
+
+
+                var order = new List<ILayoutable>();
+                Size MeasureOverride(ILayoutable control, Size size)
+                {
+                    order.Add(control);
+                    return new Size(10, 10);
+                }
+
+                root.DoMeasureOverride = MeasureOverride;
+                control1.DoMeasureOverride = MeasureOverride;
+                control2.DoMeasureOverride = MeasureOverride;
+                target.ExecuteInitialLayoutPass(root);
+
+                control2.InvalidateMeasure();
+                root.InvalidateMeasure();
+
+                order.Clear();
+                target.ExecuteLayoutPass();
+
+                Assert.Equal(new ILayoutable[] { root, control2 }, order);
+            }
+        }
+
+        [Fact]
+        public void Doesnt_Measure_Non_Invalidated_Root()
+        {
+            var target = new LayoutManager();
+
+            using (Start(target))
+            {
+                var control = new LayoutTestControl();
+                var root = new LayoutTestRoot { Child = control };
+
+                target.ExecuteInitialLayoutPass(root);
+                root.Measured = root.Arranged = false;
+                control.Measured = control.Arranged = false;
+
+                control.InvalidateMeasure();
+                target.ExecuteLayoutPass();
+
+                Assert.False(root.Measured);
+                Assert.False(root.Arranged);
+                Assert.True(control.Measured);
+                Assert.True(control.Arranged);
+            }
+        }
+
+        [Fact]
+        public void Doesnt_Measure_Removed_Control()
+        {
+            var target = new LayoutManager();
+
+            using (Start(target))
+            {
+                var control = new LayoutTestControl();
+                var root = new LayoutTestRoot { Child = control };
+
+                target.ExecuteInitialLayoutPass(root);
+                control.Measured = control.Arranged = false;
+
+                control.InvalidateMeasure();
+                root.Child = null;
+                target.ExecuteLayoutPass();
+
+                Assert.False(control.Measured);
+                Assert.False(control.Arranged);
+            }
+        }
+
+        [Fact]
+        public void Measures_Root_With_Infinity()
+        {
+            var target = new LayoutManager();
+
+            using (Start(target))
+            {
+                var root = new LayoutTestRoot();
+                var availableSize = default(Size);
+
+                // Should not measure with this size.
+                root.MaxClientSize = new Size(123, 456);
+
+                root.DoMeasureOverride = (_, s) =>
+                {
+                    availableSize = s;
+                    return new Size(100, 100);
+                };
+
+                target.ExecuteInitialLayoutPass(root);
+
+                Assert.Equal(Size.Infinity, availableSize);
+            }
+        }
+
+        [Fact]
+        public void Arranges_Root_With_DesiredSize()
+        {
+            var target = new LayoutManager();
+ 
+            using (Start(target))
+            {
+                var root = new LayoutTestRoot
+                {
+                    Width = 100,
+                    Height = 100,
+                };
+ 
+                var arrangeSize = default(Size);
+ 
+                root.DoArrangeOverride = (_, s) =>
+                {
+                    arrangeSize = s;
+                    return s;
+                };
+ 
+                target.ExecuteInitialLayoutPass(root);
+                Assert.Equal(new Size(100, 100), arrangeSize);
+ 
+                root.Width = 120;
+ 
+                target.ExecuteLayoutPass();
+                Assert.Equal(new Size(120, 100), arrangeSize);
+            }
+        }
+
+        [Fact]
+        public void Invalidating_Child_Remeasures_Parent()
+        {
+            var target = new LayoutManager();
+
+            using (Start(target))
+            {
+                AvaloniaLocator.CurrentMutable.Bind<ILayoutManager>().ToConstant(target);
 
                 Border border;
                 StackPanel panel;
 
-                var root = new TestLayoutRoot
+                var root = new LayoutTestRoot
                 {
                     Child = panel = new StackPanel
                     {
@@ -31,15 +282,22 @@ namespace Avalonia.Layout.UnitTests
                     }
                 };
 
-                layoutManager.ExecuteInitialLayoutPass(root);
+                target.ExecuteInitialLayoutPass(root);
                 Assert.Equal(new Size(0, 0), root.DesiredSize);
 
                 border.Width = 100;
                 border.Height = 100;
 
-                layoutManager.ExecuteLayoutPass();
+                target.ExecuteLayoutPass();
                 Assert.Equal(new Size(100, 100), panel.DesiredSize);
             }                
         }
+
+        private IDisposable Start(LayoutManager layoutManager)
+        {
+            var result = AvaloniaLocator.EnterScope();
+            AvaloniaLocator.CurrentMutable.Bind<ILayoutManager>().ToConstant(layoutManager);
+            return result;
+        }
     }
 }

+ 29 - 0
tests/Avalonia.Layout.UnitTests/LayoutTestControl.cs

@@ -0,0 +1,29 @@
+using System;
+using Avalonia.Controls;
+
+namespace Avalonia.Layout.UnitTests
+{
+    internal class LayoutTestControl : Decorator
+    {
+        public bool Measured { get; set; }
+        public bool Arranged { get; set; }
+        public Func<ILayoutable, Size, Size> DoMeasureOverride { get; set; }
+        public Func<ILayoutable, Size, Size> DoArrangeOverride { get; set; }
+
+        protected override Size MeasureOverride(Size availableSize)
+        {
+            Measured = true;
+            return DoMeasureOverride != null ?
+                DoMeasureOverride(this, availableSize) :
+                base.MeasureOverride(availableSize);
+        }
+
+        protected override Size ArrangeOverride(Size finalSize)
+        {
+            Arranged = true;
+            return DoArrangeOverride != null ?
+                DoArrangeOverride(this, finalSize) :
+                base.ArrangeOverride(finalSize);
+        }
+    }
+}

+ 43 - 0
tests/Avalonia.Layout.UnitTests/LayoutTestRoot.cs

@@ -0,0 +1,43 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+using Avalonia.UnitTests;
+
+namespace Avalonia.Layout.UnitTests
+{
+    internal class LayoutTestRoot : TestRoot, ILayoutable
+    {
+        public bool Measured { get; set; }
+        public bool Arranged { get; set; }
+        public Func<ILayoutable, Size, Size> DoMeasureOverride { get; set; }
+        public Func<ILayoutable, Size, Size> DoArrangeOverride { get; set; }
+
+        void ILayoutable.Measure(Size availableSize)
+        {
+            Measured = true;
+            Measure(availableSize);
+        }
+
+        void ILayoutable.Arrange(Rect rect)
+        {
+            Arranged = true;
+            Arrange(rect);
+        }
+
+        protected override Size MeasureOverride(Size availableSize)
+        {
+            return DoMeasureOverride != null ?
+                DoMeasureOverride(this, availableSize) :
+                base.MeasureOverride(availableSize);
+        }
+
+        protected override Size ArrangeOverride(Size finalSize)
+        {
+            Arranged = true;
+            return DoArrangeOverride != null ?
+                DoArrangeOverride(this, finalSize) :
+                base.ArrangeOverride(finalSize);
+        }
+    }
+}

+ 90 - 0
tests/Avalonia.Layout.UnitTests/LayoutableTests.cs

@@ -0,0 +1,90 @@
+using System;
+using Avalonia.Controls;
+using Moq;
+using Xunit;
+
+namespace Avalonia.Layout.UnitTests
+{
+    public class LayoutableTests
+    {
+        [Fact]
+        public void Only_Calls_LayoutManager_InvalidateMeasure_Once()
+        {
+            var target = new Mock<ILayoutManager>();
+
+            using (Start(target.Object))
+            {
+                var control = new Decorator();
+                var root = new LayoutTestRoot { Child = control };
+
+                root.Measure(Size.Infinity);
+                root.Arrange(new Rect(root.DesiredSize));
+                target.ResetCalls();
+
+                control.InvalidateMeasure();
+                control.InvalidateMeasure();
+
+                target.Verify(x => x.InvalidateMeasure(control), Times.Once());
+            }
+        }
+
+        [Fact]
+        public void Only_Calls_LayoutManager_InvalidateArrange_Once()
+        {
+            var target = new Mock<ILayoutManager>();
+
+            using (Start(target.Object))
+            {
+                var control = new Decorator();
+                var root = new LayoutTestRoot { Child = control };
+
+                root.Measure(Size.Infinity);
+                root.Arrange(new Rect(root.DesiredSize));
+                target.ResetCalls();
+
+                control.InvalidateArrange();
+                control.InvalidateArrange();
+
+                target.Verify(x => x.InvalidateArrange(control), Times.Once());
+            }
+        }
+
+        [Fact]
+        public void Attaching_Control_To_Tree_Invalidates_Parent_Measure()
+        {
+            var target = new Mock<ILayoutManager>();
+
+            using (Start(target.Object))
+            {
+                var control = new Decorator();
+                var root = new LayoutTestRoot { Child = control };
+
+                root.Measure(Size.Infinity);
+                root.Arrange(new Rect(root.DesiredSize));
+                Assert.True(control.IsMeasureValid);
+
+                root.Child = null;
+                root.Measure(Size.Infinity);
+                root.Arrange(new Rect(root.DesiredSize));
+
+                Assert.False(control.IsMeasureValid);
+                Assert.True(root.IsMeasureValid);
+
+                target.ResetCalls();
+
+                root.Child = control;
+
+                Assert.False(root.IsMeasureValid);
+                Assert.False(control.IsMeasureValid);
+                target.Verify(x => x.InvalidateMeasure(root), Times.Once());
+            }
+        }
+
+        private IDisposable Start(ILayoutManager layoutManager)
+        {
+            var result = AvaloniaLocator.EnterScope();
+            AvaloniaLocator.CurrentMutable.Bind<ILayoutManager>().ToConstant(layoutManager);
+            return result;
+        }
+    }
+}

+ 0 - 24
tests/Avalonia.Layout.UnitTests/TestLayoutRoot.cs

@@ -1,24 +0,0 @@
-// Copyright (c) The Avalonia Project. All rights reserved.
-// Licensed under the MIT license. See licence.md file in the project root for full license information.
-
-using Avalonia.Controls;
-
-namespace Avalonia.Layout.UnitTests
-{
-    internal class TestLayoutRoot : Decorator, ILayoutRoot
-    {
-        public TestLayoutRoot()
-        {
-            ClientSize = new Size(500, 500);
-        }
-
-        public Size ClientSize
-        {
-            get;
-            set;
-        }
-
-        public Size MaxClientSize => Size.Infinity;
-        public double LayoutScaling => 1;
-    }
-}

+ 1 - 0
tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj

@@ -1,6 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <Import Project="..\..\build\UnitTests.NetCore.targets" />
   <Import Project="..\..\build\Moq.props" />

+ 2 - 1
tests/Avalonia.Markup.UnitTests/ControlLocatorTests.cs

@@ -4,6 +4,7 @@
 using System;
 using System.Collections.Generic;
 using System.Reactive.Linq;
+using System.Threading.Tasks;
 using Avalonia.Controls;
 using Avalonia.UnitTests;
 using Xunit;
@@ -13,7 +14,7 @@ namespace Avalonia.Markup.UnitTests
     public class ControlLocatorTests
     {
         [Fact]
-        public async void Track_By_Name_Should_Find_Control_Added_Earlier()
+        public async Task Track_By_Name_Should_Find_Control_Added_Earlier()
         {
             TextBlock target;
             TextBlock relativeTo;

+ 55 - 60
tests/Avalonia.Markup.UnitTests/Data/BindingExpressionTests.cs

@@ -6,6 +6,7 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Reactive.Linq;
 using System.Threading;
+using System.Threading.Tasks;
 using Avalonia.Data;
 using Avalonia.Markup.Data;
 using Avalonia.UnitTests;
@@ -17,13 +18,15 @@ namespace Avalonia.Markup.UnitTests.Data
     public class BindingExpressionTests : IClassFixture<InvariantCultureFixture>
     {
         [Fact]
-        public async void Should_Get_Simple_Property_Value()
+        public async Task Should_Get_Simple_Property_Value()
         {
             var data = new Class1 { StringValue = "foo" };
             var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(string));
             var result = await target.Take(1);
 
             Assert.Equal("foo", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -35,6 +38,8 @@ namespace Avalonia.Markup.UnitTests.Data
             target.OnNext("bar");
 
             Assert.Equal("bar", data.StringValue);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -46,102 +51,87 @@ namespace Avalonia.Markup.UnitTests.Data
             target.OnNext("bar");
 
             Assert.Equal("bar", data.Foo[0]);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Convert_Get_String_To_Double()
+        public async Task Should_Convert_Get_String_To_Double()
         {
-#if NET461
-            Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
-#else
-            CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
-#endif
             var data = new Class1 { StringValue = "5.6" };
             var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double));
             var result = await target.Take(1);
 
             Assert.Equal(5.6, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Getting_Invalid_Double_String_Should_Return_BindingError()
+        public async Task Getting_Invalid_Double_String_Should_Return_BindingError()
         {
             var data = new Class1 { StringValue = "foo" };
             var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double));
             var result = await target.Take(1);
 
             Assert.IsType<BindingNotification>(result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Coerce_Get_Null_Double_String_To_UnsetValue()
+        public async Task Should_Coerce_Get_Null_Double_String_To_UnsetValue()
         {
             var data = new Class1 { StringValue = null };
             var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double));
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
         public void Should_Convert_Set_String_To_Double()
         {
-#if NET461
-            Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
-#else
-            CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
-#endif
-
             var data = new Class1 { StringValue = (5.6).ToString() };
             var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double));
 
             target.OnNext(6.7);
 
             Assert.Equal((6.7).ToString(), data.StringValue);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Convert_Get_Double_To_String()
+        public async Task Should_Convert_Get_Double_To_String()
         {
-#if NET461
-            Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
-#else
-            CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
-#endif
-
             var data = new Class1 { DoubleValue = 5.6 };
             var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string));
             var result = await target.Take(1);
 
             Assert.Equal((5.6).ToString(), result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
         public void Should_Convert_Set_Double_To_String()
         {
-#if NET461
-            Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
-#else
-            CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
-#endif
-
             var data = new Class1 { DoubleValue = 5.6 };
             var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string));
 
             target.OnNext("6.7");
 
             Assert.Equal(6.7, data.DoubleValue);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_BindingNotification_With_FallbackValue_For_NonConvertibe_Target_Value()
+        public async Task Should_Return_BindingNotification_With_FallbackValue_For_NonConvertibe_Target_Value()
         {
-#if NET461
-            Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
-#else
-            CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
-#endif
-
             var data = new Class1 { StringValue = "foo" };
             var target = new BindingExpression(
                 new ExpressionObserver(data, "StringValue"),
@@ -156,17 +146,13 @@ namespace Avalonia.Markup.UnitTests.Data
                     BindingErrorType.Error,
                     42),
                 result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_BindingNotification_With_FallbackValue_For_NonConvertibe_Target_Value_With_Data_Validation()
+        public async Task Should_Return_BindingNotification_With_FallbackValue_For_NonConvertibe_Target_Value_With_Data_Validation()
         {
-#if NET461
-            Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
-#else
-            CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
-#endif
-
             var data = new Class1 { StringValue = "foo" };
             var target = new BindingExpression(
                 new ExpressionObserver(data, "StringValue", true),
@@ -181,17 +167,13 @@ namespace Avalonia.Markup.UnitTests.Data
                     BindingErrorType.Error,
                     42),
                 result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_BindingNotification_For_Invalid_FallbackValue()
+        public async Task Should_Return_BindingNotification_For_Invalid_FallbackValue()
         {
-#if NET461
-            Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
-#else
-            CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
-#endif
-
             var data = new Class1 { StringValue = "foo" };
             var target = new BindingExpression(
                 new ExpressionObserver(data, "StringValue"),
@@ -203,21 +185,17 @@ namespace Avalonia.Markup.UnitTests.Data
             Assert.Equal(
                 new BindingNotification(
                     new AggregateException(
-                        new InvalidCastException("Could not convert 'foo' to 'System.Int32'"),
+                        new InvalidCastException("'foo' is not a valid number."),
                         new InvalidCastException("Could not convert FallbackValue 'bar' to 'System.Int32'")),
                     BindingErrorType.Error),
                 result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_BindingNotification_For_Invalid_FallbackValue_With_Data_Validation()
+        public async Task Should_Return_BindingNotification_For_Invalid_FallbackValue_With_Data_Validation()
         {
-#if NET461
-            Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
-#else
-            CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
-#endif
-
             var data = new Class1 { StringValue = "foo" };
             var target = new BindingExpression(
                 new ExpressionObserver(data, "StringValue", true),
@@ -229,10 +207,12 @@ namespace Avalonia.Markup.UnitTests.Data
             Assert.Equal(
                 new BindingNotification(
                     new AggregateException(
-                        new InvalidCastException("Could not convert 'foo' to 'System.Int32'"),
+                        new InvalidCastException("'foo' is not a valid number."),
                         new InvalidCastException("Could not convert FallbackValue 'bar' to 'System.Int32'")),
                     BindingErrorType.Error),
                 result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -244,6 +224,8 @@ namespace Avalonia.Markup.UnitTests.Data
             target.OnNext("foo");
 
             Assert.Equal(5.6, data.DoubleValue);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -259,6 +241,8 @@ namespace Avalonia.Markup.UnitTests.Data
             target.OnNext("foo");
 
             Assert.Equal(9.8, data.DoubleValue);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -270,6 +254,8 @@ namespace Avalonia.Markup.UnitTests.Data
             target.OnNext(null);
 
             Assert.Equal(0, data.DoubleValue);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -281,6 +267,8 @@ namespace Avalonia.Markup.UnitTests.Data
             target.OnNext(AvaloniaProperty.UnsetValue);
 
             Assert.Equal(0, data.DoubleValue);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -288,6 +276,7 @@ namespace Avalonia.Markup.UnitTests.Data
         {
             var data = new Class1 { DoubleValue = 5.6 };
             var converter = new Mock<IValueConverter>();
+
             var target = new BindingExpression(
                 new ExpressionObserver(data, "DoubleValue"),
                 typeof(string),
@@ -296,7 +285,9 @@ namespace Avalonia.Markup.UnitTests.Data
 
             target.Subscribe(_ => { });
 
-            converter.Verify(x => x.Convert(5.6, typeof(string), "foo", CultureInfo.CurrentUICulture));
+            converter.Verify(x => x.Convert(5.6, typeof(string), "foo", CultureInfo.CurrentCulture));
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -312,7 +303,9 @@ namespace Avalonia.Markup.UnitTests.Data
 
             target.OnNext("bar");
 
-            converter.Verify(x => x.ConvertBack("bar", typeof(double), "foo", CultureInfo.CurrentUICulture));
+            converter.Verify(x => x.ConvertBack("bar", typeof(double), "foo", CultureInfo.CurrentCulture));
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -339,6 +332,8 @@ namespace Avalonia.Markup.UnitTests.Data
                         BindingErrorType.Error)
                 },
                 result);
+
+            GC.KeepAlive(data);
         }
 
         private class Class1 : NotifyingBase

+ 3 - 2
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_AttachedProperty.cs

@@ -4,6 +4,7 @@
 using System;
 using System.Collections.Generic;
 using System.Reactive.Linq;
+using System.Threading.Tasks;
 using Avalonia.Diagnostics;
 using Avalonia.Markup.Data;
 using Xunit;
@@ -18,7 +19,7 @@ namespace Avalonia.Markup.UnitTests.Data
         }
 
         [Fact]
-        public async void Should_Get_Attached_Property_Value()
+        public async Task Should_Get_Attached_Property_Value()
         {
             var data = new Class1();
             var target = new ExpressionObserver(data, "(Owner.Foo)");
@@ -30,7 +31,7 @@ namespace Avalonia.Markup.UnitTests.Data
         }
 
         [Fact]
-        public async void Should_Get_Chained_Attached_Property_Value()
+        public async Task Should_Get_Chained_Attached_Property_Value()
         {
             var data = new Class1
             {

+ 2 - 1
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_AvaloniaProperty.cs

@@ -4,6 +4,7 @@
 using System;
 using System.Collections.Generic;
 using System.Reactive.Linq;
+using System.Threading.Tasks;
 using Avalonia.Diagnostics;
 using Avalonia.Markup.Data;
 using Xunit;
@@ -18,7 +19,7 @@ namespace Avalonia.Markup.UnitTests.Data
         }
 
         [Fact]
-        public async void Should_Get_Simple_Property_Value()
+        public async Task Should_Get_Simple_Property_Value()
         {
             var data = new Class1();
             var target = new ExpressionObserver(data, "Foo");

+ 9 - 0
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs

@@ -28,6 +28,8 @@ namespace Avalonia.Markup.UnitTests.Data
             observer.SetValue(-5);
 
             Assert.False(validationMessageFound);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -43,6 +45,8 @@ namespace Avalonia.Markup.UnitTests.Data
             observer.SetValue(-5);
 
             Assert.True(validationMessageFound);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -102,6 +106,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 new BindingNotification(new Exception("Must be positive"), BindingErrorType.DataValidationError, 5),
                 new BindingNotification(5),
             }, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -147,6 +153,9 @@ namespace Avalonia.Markup.UnitTests.Data
                     BindingErrorType.Error,
                     AvaloniaProperty.UnsetValue),
             }, result);
+
+            GC.KeepAlive(container);
+            GC.KeepAlive(inner);
         }
 
         public class ExceptionTest : NotifyingBase

+ 56 - 11
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs

@@ -5,6 +5,7 @@ using System;
 using System.Collections.Generic;
 using System.Collections.ObjectModel;
 using System.Reactive.Linq;
+using System.Threading.Tasks;
 using Avalonia.Collections;
 using Avalonia.Diagnostics;
 using Avalonia.Markup.Data;
@@ -16,113 +17,135 @@ namespace Avalonia.Markup.UnitTests.Data
     public class ExpressionObserverTests_Indexer
     {
         [Fact]
-        public async void Should_Get_Array_Value()
+        public async Task Should_Get_Array_Value()
         {
             var data = new { Foo = new [] { "foo", "bar" } };
             var target = new ExpressionObserver(data, "Foo[1]");
             var result = await target.Take(1);
 
             Assert.Equal("bar", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_UnsetValue_For_Invalid_Array_Index()
+        public async Task Should_Get_UnsetValue_For_Invalid_Array_Index()
         {
             var data = new { Foo = new[] { "foo", "bar" } };
             var target = new ExpressionObserver(data, "Foo[invalid]");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_UnsetValue_For_Invalid_Dictionary_Index()
+        public async Task Should_Get_UnsetValue_For_Invalid_Dictionary_Index()
         {
             var data = new { Foo = new Dictionary<int, string> { { 1, "foo" } } };
             var target = new ExpressionObserver(data, "Foo[invalid]");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_UnsetValue_For_Object_Without_Indexer()
+        public async Task Should_Get_UnsetValue_For_Object_Without_Indexer()
         {
             var data = new { Foo = 5 };
             var target = new ExpressionObserver(data, "Foo[noindexer]");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_MultiDimensional_Array_Value()
+        public async Task Should_Get_MultiDimensional_Array_Value()
         {
             var data = new { Foo = new[,] { { "foo", "bar" }, { "baz", "qux" } } };
             var target = new ExpressionObserver(data, "Foo[1, 1]");
             var result = await target.Take(1);
 
             Assert.Equal("qux", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_Value_For_String_Indexer()
+        public async Task Should_Get_Value_For_String_Indexer()
         {
             var data = new { Foo = new Dictionary<string, string> { { "foo", "bar" }, { "baz", "qux" } } };
             var target = new ExpressionObserver(data, "Foo[foo]");
             var result = await target.Take(1);
 
             Assert.Equal("bar", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_Value_For_Non_String_Indexer()
+        public async Task Should_Get_Value_For_Non_String_Indexer()
         {
             var data = new { Foo = new Dictionary<double, string> { { 1.0, "bar" }, { 2.0, "qux" } } };
             var target = new ExpressionObserver(data, "Foo[1.0]");
             var result = await target.Take(1);
 
             Assert.Equal("bar", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Array_Out_Of_Bounds_Should_Return_UnsetValue()
+        public async Task Array_Out_Of_Bounds_Should_Return_UnsetValue()
         {
             var data = new { Foo = new[] { "foo", "bar" } };
             var target = new ExpressionObserver(data, "Foo[2]");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Array_With_Wrong_Dimensions_Should_Return_UnsetValue()
+        public async Task Array_With_Wrong_Dimensions_Should_Return_UnsetValue()
         {
             var data = new { Foo = new[] { "foo", "bar" } };
             var target = new ExpressionObserver(data, "Foo[1,2]");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void List_Out_Of_Bounds_Should_Return_UnsetValue()
+        public async Task List_Out_Of_Bounds_Should_Return_UnsetValue()
         {
             var data = new { Foo = new List<string> { "foo", "bar" } };
             var target = new ExpressionObserver(data, "Foo[2]");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_List_Value()
+        public async Task Should_Get_List_Value()
         {
             var data = new { Foo = new List<string> { "foo", "bar" } };
             var target = new ExpressionObserver(data, "Foo[1]");
             var result = await target.Take(1);
 
             Assert.Equal("bar", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -139,6 +162,8 @@ namespace Avalonia.Markup.UnitTests.Data
 
             Assert.Equal(new[] { AvaloniaProperty.UnsetValue, "baz" }, result);
             Assert.Null(((INotifyCollectionChangedDebug)data.Foo).GetCollectionChangedSubscribers());
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -155,6 +180,8 @@ namespace Avalonia.Markup.UnitTests.Data
 
             Assert.Equal(new[] { "foo", "bar" }, result);
             Assert.Null(((INotifyCollectionChangedDebug)data.Foo).GetCollectionChangedSubscribers());
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -171,6 +198,8 @@ namespace Avalonia.Markup.UnitTests.Data
 
             Assert.Equal(new[] { "bar", "baz" }, result);
             Assert.Null(((INotifyCollectionChangedDebug)data.Foo).GetCollectionChangedSubscribers());
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -187,6 +216,9 @@ namespace Avalonia.Markup.UnitTests.Data
             data.Foo.Move(0, 1);
 
             Assert.Equal(new[] { "bar", "foo" }, result);
+
+            GC.KeepAlive(sub);
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -200,6 +232,9 @@ namespace Avalonia.Markup.UnitTests.Data
             data.Foo.Clear();
 
             Assert.Equal(new[] { "bar", AvaloniaProperty.UnsetValue }, result);
+
+            GC.KeepAlive(sub);
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -220,6 +255,8 @@ namespace Avalonia.Markup.UnitTests.Data
             var expected = new[] { "bar", "bar2" };
             Assert.Equal(expected, result);
             Assert.Equal(0, data.Foo.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -234,6 +271,8 @@ namespace Avalonia.Markup.UnitTests.Data
             }
 
             Assert.Equal("baz", data.Foo[1]);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -254,6 +293,8 @@ namespace Avalonia.Markup.UnitTests.Data
             }
 
             Assert.Equal(4, data.Foo["foo"]);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -274,6 +315,8 @@ namespace Avalonia.Markup.UnitTests.Data
             }
 
             Assert.Equal(4, data.Foo["bar"]);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -291,6 +334,8 @@ namespace Avalonia.Markup.UnitTests.Data
             }
             
             Assert.Equal("bar2", data.Foo["foo"]);
+
+            GC.KeepAlive(data);
         }
 
         private class NonIntegerIndexer : NotifyingBase

+ 4 - 1
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Lifetime.cs

@@ -90,7 +90,8 @@ namespace Avalonia.Markup.UnitTests.Data
         {
             var scheduler = new TestScheduler();
             var update = scheduler.CreateColdObservable<Unit>();
-            var target = new ExpressionObserver(() => new { Foo = "foo" }, "Foo", update);
+            var data = new { Foo = "foo" };
+            var target = new ExpressionObserver(() => data, "Foo", update);
             var result = new List<object>();
 
             using (target.Subscribe(x => result.Add(x)))
@@ -101,6 +102,8 @@ namespace Avalonia.Markup.UnitTests.Data
 
             Assert.Equal(new[] { "foo" }, result);
             Assert.All(update.Subscriptions, x => Assert.NotEqual(Subscription.Infinite, x.Unsubscribe));
+
+            GC.KeepAlive(data);
         }
 
         private Recorded<Notification<object>> OnNext(long time, object value)

+ 24 - 7
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Negation.cs

@@ -3,6 +3,7 @@
 
 using System;
 using System.Reactive.Linq;
+using System.Threading.Tasks;
 using Avalonia.Data;
 using Avalonia.Markup.Data;
 using Xunit;
@@ -12,57 +13,67 @@ namespace Avalonia.Markup.UnitTests.Data
     public class ExpressionObserverTests_Negation
     {
         [Fact]
-        public async void Should_Negate_Boolean_Value()
+        public async Task Should_Negate_Boolean_Value()
         {
             var data = new { Foo = true };
             var target = new ExpressionObserver(data, "!Foo");
             var result = await target.Take(1);
 
             Assert.Equal(false, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Negate_0()
+        public async Task Should_Negate_0()
         {
             var data = new { Foo = 0 };
             var target = new ExpressionObserver(data, "!Foo");
             var result = await target.Take(1);
 
             Assert.Equal(true, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Negate_1()
+        public async Task Should_Negate_1()
         {
             var data = new { Foo = 1 };
             var target = new ExpressionObserver(data, "!Foo");
             var result = await target.Take(1);
 
             Assert.Equal(false, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Negate_False_String()
+        public async Task Should_Negate_False_String()
         {
             var data = new { Foo = "false" };
             var target = new ExpressionObserver(data, "!Foo");
             var result = await target.Take(1);
 
             Assert.Equal(true, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Negate_True_String()
+        public async Task Should_Negate_True_String()
         {
             var data = new { Foo = "True" };
             var target = new ExpressionObserver(data, "!Foo");
             var result = await target.Take(1);
 
             Assert.Equal(false, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_BindingNotification_For_String_Not_Convertible_To_Boolean()
+        public async Task Should_Return_BindingNotification_For_String_Not_Convertible_To_Boolean()
         {
             var data = new { Foo = "foo" };
             var target = new ExpressionObserver(data, "!Foo");
@@ -73,10 +84,12 @@ namespace Avalonia.Markup.UnitTests.Data
                     new InvalidCastException($"Unable to convert 'foo' to bool."),
                     BindingErrorType.Error), 
                 result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_BindingNotification_For_Value_Not_Convertible_To_Boolean()
+        public async Task Should_Return_BindingNotification_For_Value_Not_Convertible_To_Boolean()
         {
             var data = new { Foo = new object() };
             var target = new ExpressionObserver(data, "!Foo");
@@ -87,6 +100,8 @@ namespace Avalonia.Markup.UnitTests.Data
                     new InvalidCastException($"Unable to convert 'System.Object' to bool."),
                     BindingErrorType.Error),
                 result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -96,6 +111,8 @@ namespace Avalonia.Markup.UnitTests.Data
             var target = new ExpressionObserver(data, "!Foo");
 
             Assert.False(target.SetValue("bar"));
+
+            GC.KeepAlive(data);
         }
     }
 }

+ 12 - 0
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs

@@ -29,6 +29,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 sync.ExecutePostedCallbacks();
 
                 Assert.Equal(new[] { source }, result);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -47,6 +49,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 sync.ExecutePostedCallbacks();
 
                 Assert.Equal(new[] { "foo", "bar" }, result);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -67,6 +71,8 @@ namespace Avalonia.Markup.UnitTests.Data
 
                 sub.Dispose();
                 Assert.Equal(0, data.PropertyChangedSubscriptionCount);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -87,6 +93,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 // What does it mean to have data validation on an observable? Without a use-case
                 // it's hard to know what to do here so for the moment the value is returned.
                 Assert.Equal(new[] { "foo", "bar" }, result);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -107,6 +115,8 @@ namespace Avalonia.Markup.UnitTests.Data
 
                 sub.Dispose();
                 Assert.Equal(0, data.PropertyChangedSubscriptionCount);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -132,6 +142,8 @@ namespace Avalonia.Markup.UnitTests.Data
                     result);
 
                 sub.Dispose();
+
+                GC.KeepAlive(data);
             }
         }
 

+ 69 - 9
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs

@@ -11,19 +11,22 @@ using Avalonia.Data;
 using Avalonia.Markup.Data;
 using Avalonia.UnitTests;
 using Xunit;
+using System.Threading.Tasks;
 
 namespace Avalonia.Markup.UnitTests.Data
 {
     public class ExpressionObserverTests_Property
     {
         [Fact]
-        public async void Should_Get_Simple_Property_Value()
+        public async Task Should_Get_Simple_Property_Value()
         {
             var data = new { Foo = "foo" };
             var target = new ExpressionObserver(data, "Foo");
             var result = await target.Take(1);
 
             Assert.Equal("foo", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -35,76 +38,92 @@ namespace Avalonia.Markup.UnitTests.Data
             target.Subscribe(_ => { });
 
             Assert.Equal(typeof(string), target.ResultType);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_Simple_Property_Value_Null()
+        public async Task Should_Get_Simple_Property_Value_Null()
         {
             var data = new { Foo = (string)null };
             var target = new ExpressionObserver(data, "Foo");
             var result = await target.Take(1);
 
             Assert.Null(result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_Simple_Property_From_Base_Class()
+        public async Task Should_Get_Simple_Property_From_Base_Class()
         {
             var data = new Class3 { Foo = "foo" };
             var target = new ExpressionObserver(data, "Foo");
             var result = await target.Take(1);
 
             Assert.Equal("foo", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_UnsetValue_For_Root_Null()
+        public async Task Should_Return_UnsetValue_For_Root_Null()
         {
             var data = new Class3 { Foo = "foo" };
             var target = new ExpressionObserver(default(object), "Foo");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_UnsetValue_For_Root_UnsetValue()
+        public async Task Should_Return_UnsetValue_For_Root_UnsetValue()
         {
             var data = new Class3 { Foo = "foo" };
             var target = new ExpressionObserver(AvaloniaProperty.UnsetValue, "Foo");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_UnsetValue_For_Observable_Root_Null()
+        public async Task Should_Return_UnsetValue_For_Observable_Root_Null()
         {
             var data = new Class3 { Foo = "foo" };
             var target = new ExpressionObserver(Observable.Return(default(object)), "Foo");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_UnsetValue_For_Observable_Root_UnsetValue()
+        public async Task Should_Return_UnsetValue_For_Observable_Root_UnsetValue()
         {
             var data = new Class3 { Foo = "foo" };
             var target = new ExpressionObserver(Observable.Return(AvaloniaProperty.UnsetValue), "Foo");
             var result = await target.Take(1);
 
             Assert.Equal(AvaloniaProperty.UnsetValue, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Get_Simple_Property_Chain()
+        public async Task Should_Get_Simple_Property_Chain()
         {
             var data = new { Foo = new { Bar = new { Baz = "baz" } }  };
             var target = new ExpressionObserver(data, "Foo.Bar.Baz");
             var result = await target.Take(1);
 
             Assert.Equal("baz", result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -116,10 +135,12 @@ namespace Avalonia.Markup.UnitTests.Data
             target.Subscribe(_ => { });
 
             Assert.Equal(typeof(string), target.ResultType);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
-        public async void Should_Return_BindingNotification_Error_For_Broken_Chain()
+        public async Task Should_Return_BindingNotification_Error_For_Broken_Chain()
         {
             var data = new { Foo = new { Bar = 1 } };
             var target = new ExpressionObserver(data, "Foo.Bar.Baz");
@@ -131,6 +152,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 new BindingNotification(
                     new MissingMemberException("Could not find CLR property 'Baz' on '1'"), BindingErrorType.Error),
                 result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -151,6 +174,8 @@ namespace Avalonia.Markup.UnitTests.Data
                         AvaloniaProperty.UnsetValue),
                 },
                 result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -160,6 +185,8 @@ namespace Avalonia.Markup.UnitTests.Data
             var target = new ExpressionObserver(data, "Foo.Bar.Baz");
 
             Assert.Null(target.ResultType);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -177,6 +204,8 @@ namespace Avalonia.Markup.UnitTests.Data
             sub.Dispose();
 
             Assert.Equal(0, data.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -205,6 +234,8 @@ namespace Avalonia.Markup.UnitTests.Data
             sub.Dispose();
 
             Assert.Equal(0, data.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -224,6 +255,8 @@ namespace Avalonia.Markup.UnitTests.Data
 
             Assert.Equal(0, data.PropertyChangedSubscriptionCount);
             Assert.Equal(0, data.Next.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -245,6 +278,8 @@ namespace Avalonia.Markup.UnitTests.Data
             Assert.Equal(0, data.PropertyChangedSubscriptionCount);
             Assert.Equal(0, data.Next.PropertyChangedSubscriptionCount);
             Assert.Equal(0, old.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -286,6 +321,8 @@ namespace Avalonia.Markup.UnitTests.Data
             Assert.Equal(0, data.PropertyChangedSubscriptionCount);
             Assert.Equal(0, data.Next.PropertyChangedSubscriptionCount);
             Assert.Equal(0, old.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -318,6 +355,8 @@ namespace Avalonia.Markup.UnitTests.Data
             Assert.Equal(0, data.Next.PropertyChangedSubscriptionCount);
             Assert.Equal(0, breaking.PropertyChangedSubscriptionCount);
             Assert.Equal(0, old.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -334,6 +373,8 @@ namespace Avalonia.Markup.UnitTests.Data
             update.OnNext(Unit.Default);
 
             Assert.Equal(new[] { "foo", "bar" }, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -374,6 +415,8 @@ namespace Avalonia.Markup.UnitTests.Data
             Assert.Equal(new[] { "foo", "bar" }, result1);
             Assert.Equal(new[] { "foo", "bar" }, result2);
             Assert.Equal(new[] { "bar" }, result3);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -391,6 +434,8 @@ namespace Avalonia.Markup.UnitTests.Data
             sub2.Dispose();
 
             Assert.Equal(0, data.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -405,6 +450,8 @@ namespace Avalonia.Markup.UnitTests.Data
             }
 
             Assert.Equal("bar", data.Foo);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -419,6 +466,8 @@ namespace Avalonia.Markup.UnitTests.Data
             }
 
             Assert.Equal("baz", ((Class2)data.Next).Bar);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -431,6 +480,8 @@ namespace Avalonia.Markup.UnitTests.Data
             {
                 Assert.False(target.SetValue("baz"));
             }
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -444,6 +495,8 @@ namespace Avalonia.Markup.UnitTests.Data
             target.SetValue("bar");
 
             Assert.Equal(new[] { null, "bar" }, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -457,6 +510,8 @@ namespace Avalonia.Markup.UnitTests.Data
             target.SetValue("bar");
 
             Assert.Equal(new[] { null, "bar" }, result);
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -469,6 +524,8 @@ namespace Avalonia.Markup.UnitTests.Data
             {
                 Assert.False(target.SetValue("baz"));
             }
+
+            GC.KeepAlive(data);
         }
 
         [Fact]
@@ -498,6 +555,9 @@ namespace Avalonia.Markup.UnitTests.Data
 
             Assert.Equal(0, first.PropertyChangedSubscriptionCount);
             Assert.Equal(0, second.PropertyChangedSubscriptionCount);
+
+            GC.KeepAlive(first);
+            GC.KeepAlive(second);
         }
 
         [Fact]

+ 12 - 0
tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs

@@ -30,6 +30,8 @@ namespace Avalonia.Markup.UnitTests.Data
 
                 Assert.Equal(1, result.Count);
                 Assert.IsType<Task<string>>(result[0]);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -45,6 +47,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 var sub = target.Subscribe(x => result.Add(x));
 
                 Assert.Equal(new[] { "foo" }, result);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -63,6 +67,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 sync.ExecutePostedCallbacks();
 
                 Assert.Equal(new[] { "foo" }, result);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -88,6 +94,8 @@ namespace Avalonia.Markup.UnitTests.Data
                             BindingErrorType.Error)
                     }, 
                     result);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -110,6 +118,8 @@ namespace Avalonia.Markup.UnitTests.Data
                             BindingErrorType.Error)
                     },
                     result);
+
+                GC.KeepAlive(data);
             }
         }
 
@@ -130,6 +140,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 // What does it mean to have data validation on a Task? Without a use-case it's
                 // hard to know what to do here so for the moment the value is returned.
                 Assert.Equal(new [] { "foo" }, result);
+
+                GC.KeepAlive(data);
             }
         }
 

+ 2 - 0
tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs

@@ -35,6 +35,8 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins
                 new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError),
                 new BindingNotification(6),
             }, result);
+
+            GC.KeepAlive(data);
         }
 
         public class Data : NotifyingBase

+ 1 - 1
tests/Avalonia.Markup.UnitTests/Properties/AssemblyInfo.cs

@@ -37,4 +37,4 @@ using Xunit;
 [assembly: AssemblyFileVersion("1.0.0.0")]
 
 // Don't run tests in parallel.
-[assembly: CollectionBehavior(DisableTestParallelization = true)]
+[assembly: CollectionBehavior(MaxParallelThreads = 1)]

+ 1 - 0
tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj

@@ -1,6 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <Import Project="..\..\build\UnitTests.NetCore.targets" />
   <Import Project="..\..\build\Moq.props" />

+ 2 - 1
tests/Avalonia.Markup.Xaml.UnitTests/Data/MultiBindingTests.cs

@@ -10,13 +10,14 @@ using Moq;
 using Avalonia.Controls;
 using Avalonia.Markup.Xaml.Data;
 using Xunit;
+using System.Threading.Tasks;
 
 namespace Avalonia.Markup.Xaml.UnitTests.Data
 {
     public class MultiBindingTests
     {
         [Fact]
-        public async void OneWay_Binding_Should_Be_Set_Up()
+        public async Task OneWay_Binding_Should_Be_Set_Up()
         {
             var source = new { A = 1, B = 2, C = 3 };
             var binding = new MultiBinding

+ 1 - 1
tests/Avalonia.Markup.Xaml.UnitTests/Properties/AssemblyInfo.cs

@@ -7,4 +7,4 @@ using Xunit;
 [assembly: AssemblyTitle("Avalonia.Markup.Xaml.UnitTests")]
 
 // Don't run tests in parallel.
-[assembly: CollectionBehavior(DisableTestParallelization = true)]
+[assembly: CollectionBehavior(MaxParallelThreads = 1)]

+ 1 - 0
tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj

@@ -1,6 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <Import Project="..\..\build\UnitTests.NetCore.targets" />
   <Import Project="..\..\build\Moq.props" />

+ 7 - 1
tests/Avalonia.Styling.UnitTests/SelectorTests_Child.cs

@@ -6,6 +6,7 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Reactive;
 using System.Reactive.Linq;
+using System.Threading.Tasks;
 using Avalonia.Collections;
 using Avalonia.Controls;
 using Avalonia.Data;
@@ -45,7 +46,7 @@ namespace Avalonia.Styling.UnitTests
         }
 
         [Fact]
-        public async void Child_Matches_Control_When_It_Is_Child_OfType_And_Class()
+        public async Task Child_Matches_Control_When_It_Is_Child_OfType_And_Class()
         {
             var parent = new TestLogical1();
             var child = new TestLogical2();
@@ -144,6 +145,11 @@ namespace Avalonia.Styling.UnitTests
                 throw new NotImplementedException();
             }
 
+            public void NotifyAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
+            {
+                throw new NotImplementedException();
+            }
+
             public void NotifyDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
             {
                 throw new NotImplementedException();

+ 5 - 0
tests/Avalonia.Styling.UnitTests/SelectorTests_Descendent.cs

@@ -175,6 +175,11 @@ namespace Avalonia.Styling.UnitTests
                 throw new NotImplementedException();
             }
 
+            public void NotifyAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
+            {
+                throw new NotImplementedException();
+            }
+
             public void NotifyDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
             {
                 throw new NotImplementedException();

+ 1 - 3
tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj

@@ -2,6 +2,7 @@
   <PropertyGroup>
     <TargetFrameworks>net461;netcoreapp1.1</TargetFrameworks>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+    <OutputType>Library</OutputType>
   </PropertyGroup>
   <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
     <DebugSymbols>true</DebugSymbols>
@@ -51,8 +52,5 @@
   <Import Project="..\..\build\Moq.props" />
   <Import Project="..\..\build\Rx.props" />
   <Import Project="..\..\build\XUnit.props" />
-  <ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp1.1'">
-      <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />
-  </ItemGroup>
   <Import Condition="'$(TargetFramework)' == 'net461'" Project="$(MSBuildThisFileDirectory)..\..\src\Shared\nuget.workaround.targets" />
 </Project>

+ 12 - 3
tests/Avalonia.UnitTests/InvariantCultureFixture.cs

@@ -20,13 +20,22 @@ namespace Avalonia.UnitTests
 
         public InvariantCultureFixture()
         {
-            _restore = CultureInfo.CurrentUICulture;
-            CultureInfo.CurrentUICulture = CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+#if NET461
+            _restore = Thread.CurrentThread.CurrentCulture;
+            Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
+#else
+            _restore = CultureInfo.CurrentCulture;
+            CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+#endif
         }
 
         public void Dispose()
         {
-            CultureInfo.CurrentUICulture = CultureInfo.CurrentCulture = _restore;
+#if NET461
+            Thread.CurrentThread.CurrentCulture = _restore;
+#else
+            CultureInfo.CurrentCulture = _restore;
+#endif
         }
     }
 }

+ 1 - 1
tests/Avalonia.UnitTests/TestRoot.cs

@@ -43,7 +43,7 @@ namespace Avalonia.UnitTests
 
         public Size ClientSize => new Size(100, 100);
 
-        public Size MaxClientSize => Size.Infinity;
+        public Size MaxClientSize { get; set; } = Size.Infinity;
 
         public double LayoutScaling => 1;
 

+ 6 - 0
tests/Avalonia.UnitTests/TestServices.cs

@@ -50,6 +50,7 @@ namespace Avalonia.UnitTests
         public static readonly TestServices RealFocus = new TestServices(
             focusManager: new FocusManager(),
             keyboardDevice: () => new KeyboardDevice(),
+            keyboardNavigation: new KeyboardNavigationHandler(),
             inputManager: new InputManager());
 
         public static readonly TestServices RealLayoutManager = new TestServices(
@@ -63,6 +64,7 @@ namespace Avalonia.UnitTests
             IFocusManager focusManager = null,
             IInputManager inputManager = null,
             Func<IKeyboardDevice> keyboardDevice = null,
+            IKeyboardNavigationHandler keyboardNavigation = null,
             ILayoutManager layoutManager = null,
             IRuntimePlatform platform = null,
             Func<IRenderRoot, IRenderLoop, IRenderer> renderer = null,
@@ -79,6 +81,7 @@ namespace Avalonia.UnitTests
             FocusManager = focusManager;
             InputManager = inputManager;
             KeyboardDevice = keyboardDevice;
+            KeyboardNavigation = keyboardNavigation;
             LayoutManager = layoutManager;
             Platform = platform;
             Renderer = renderer;
@@ -96,6 +99,7 @@ namespace Avalonia.UnitTests
         public IInputManager InputManager { get; }
         public IFocusManager FocusManager { get; }
         public Func<IKeyboardDevice> KeyboardDevice { get; }
+        public IKeyboardNavigationHandler KeyboardNavigation { get; }
         public ILayoutManager LayoutManager { get; }
         public IRuntimePlatform Platform { get; }
         public Func<IRenderRoot, IRenderLoop, IRenderer> Renderer { get; }
@@ -113,6 +117,7 @@ namespace Avalonia.UnitTests
             IFocusManager focusManager = null,
             IInputManager inputManager = null,
             Func<IKeyboardDevice> keyboardDevice = null,
+            IKeyboardNavigationHandler keyboardNavigation = null,
             ILayoutManager layoutManager = null,
             IRuntimePlatform platform = null,
             Func<IRenderRoot, IRenderLoop, IRenderer> renderer = null,
@@ -131,6 +136,7 @@ namespace Avalonia.UnitTests
                 focusManager: focusManager ?? FocusManager,
                 inputManager: inputManager ?? InputManager,
                 keyboardDevice: keyboardDevice ?? KeyboardDevice,
+                keyboardNavigation: keyboardNavigation ?? KeyboardNavigation,
                 layoutManager: layoutManager ?? LayoutManager,
                 platform: platform ?? Platform,
                 renderer: renderer ?? Renderer,

+ 1 - 0
tests/Avalonia.UnitTests/UnitTestApplication.cs

@@ -49,6 +49,7 @@ namespace Avalonia.UnitTests
                 .BindToSelf<IGlobalStyles>(this)
                 .Bind<IInputManager>().ToConstant(Services.InputManager)
                 .Bind<IKeyboardDevice>().ToConstant(Services.KeyboardDevice?.Invoke())
+                .Bind<IKeyboardNavigationHandler>().ToConstant(Services.KeyboardNavigation)
                 .Bind<ILayoutManager>().ToConstant(Services.LayoutManager)
                 .Bind<IRuntimePlatform>().ToConstant(Services.Platform)
                 .Bind<IRendererFactory>().ToConstant(new RendererFactory(Services.Renderer))

+ 4 - 0
tools/packages.config

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<packages>
+    <package id="Cake" version="0.18.0" />
+</packages>