mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-29 15:19:12 +08:00
1002 lines
37 KiB
C#
1002 lines
37 KiB
C#
using AtomUI.Controls.Primitives;
|
|
using AtomUI.Controls.Converters;
|
|
using Avalonia.Layout;
|
|
using AtomUI.MotionScene;
|
|
using AtomUI.Theme.Resources;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using Avalonia.Media.Transformation;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using AtomUIExpander = AtomUI.Desktop.Controls.Expander;
|
|
using AvaloniaWindow = Avalonia.Controls.Window;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.ExpanderControl;
|
|
|
|
public class ExpanderBehaviorTests
|
|
{
|
|
private const double LayoutTolerance = 0.5;
|
|
|
|
static ExpanderBehaviorTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
[Fact]
|
|
public void Content_Converter_Is_Owned_By_The_Expander_Theme_Dictionary()
|
|
{
|
|
// This is the compiled wrapper for Expander/Themes/ExpanderTheme.axaml.
|
|
var dictionary = new AtomUI.Generated.AtomUIDesktopControls.GeneratedThemeAssetResource_AD9E7F86B0580549();
|
|
dictionary.TryGetResource("StringToTextBlockConverter", null, out var resource).ShouldBeTrue();
|
|
var converter = resource.ShouldBeOfType<StringToTextBlockConverter>();
|
|
converter.VerticalAlignment.ShouldBe(VerticalAlignment.Center);
|
|
converter.HorizontalAlignment.ShouldBe(HorizontalAlignment.Stretch);
|
|
converter.TextWrapping.ShouldBe(TextWrapping.Wrap);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void String_Content_Keeps_Wrapping_And_Alignment_Despite_A_Host_Converter(bool injectHostConverter)
|
|
{
|
|
var expander = new AtomUIExpander { Header = "Header", Content = "A long string content", IsExpanded = true, IsMotionEnabled = false };
|
|
if (injectHostConverter)
|
|
{
|
|
expander.Resources["StringToTextBlockConverter"] = new StringToTextBlockConverter
|
|
{
|
|
VerticalAlignment = VerticalAlignment.Top,
|
|
TextWrapping = TextWrapping.NoWrap
|
|
};
|
|
}
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var presenter = FindTemplatePart<ContentPresenter>(expander, "PART_ContentPresenter");
|
|
presenter.ShouldNotBeNull();
|
|
var content = presenter.Content.ShouldBeOfType<Avalonia.Controls.TextBlock>();
|
|
content.Text.ShouldBe("A long string content");
|
|
content.VerticalAlignment.ShouldBe(VerticalAlignment.Center);
|
|
content.HorizontalAlignment.ShouldBe(HorizontalAlignment.Stretch);
|
|
content.TextWrapping.ShouldBe(TextWrapping.Wrap);
|
|
expander.Content = "Updated content";
|
|
Dispatcher.UIThread.RunJobs();
|
|
presenter.Content.ShouldBeOfType<Avalonia.Controls.TextBlock>().Text.ShouldBe("Updated content");
|
|
}
|
|
finally { window.Close(); }
|
|
}
|
|
|
|
[Fact]
|
|
public void Non_String_Content_Is_Preserved_By_The_Local_Converter()
|
|
{
|
|
var content = new Border { Width = 80, Height = 24 };
|
|
var expander = new AtomUIExpander { Header = "Header", Content = content, IsExpanded = true, IsMotionEnabled = false };
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var presenter = FindTemplatePart<ContentPresenter>(expander, "PART_ContentPresenter");
|
|
presenter.ShouldNotBeNull();
|
|
presenter.Content.ShouldBeSameAs(content);
|
|
content.GetVisualAncestors().ShouldContain(presenter);
|
|
}
|
|
finally { window.Close(); }
|
|
}
|
|
|
|
[Fact]
|
|
public void Custom_Header_Padding_Uses_Compact_Expand_Icon_Layout()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "This is panel header 1",
|
|
Content = "Content",
|
|
HeaderPadding = new Thickness(5),
|
|
ContentPadding = new Thickness(5),
|
|
IsMotionEnabled = false
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var headerDecorator = GetHeaderDecorator(expander);
|
|
var expandButton = GetExpandButton(expander);
|
|
var headerPresenter = GetHeaderPresenter(expander);
|
|
|
|
expandButton.Bounds.Width.ShouldBe(expandButton.IconWidth, LayoutTolerance);
|
|
expandButton.Bounds.Height.ShouldBe(expandButton.IconHeight, LayoutTolerance);
|
|
|
|
GetHorizontalGap(expandButton, headerPresenter, headerDecorator)
|
|
.ShouldBe(5, LayoutTolerance);
|
|
|
|
GetCenterY(expandButton, headerDecorator)
|
|
.ShouldBe(GetCenterY(headerPresenter, headerDecorator), LayoutTolerance);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Ghost_And_Borderless_Mode_Update_Frame_And_Content_Separator_At_Runtime()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
IsExpanded = true,
|
|
IsMotionEnabled = false,
|
|
BorderThickness = new Thickness(5)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var frame = FindVisualByName<PixelAlignedBorder>(expander, "PART_Frame");
|
|
frame.ShouldNotBeNull();
|
|
frame!.BorderThickness.ShouldBe(new Thickness(5));
|
|
frame.BorderBrush.ShouldNotBeNull();
|
|
|
|
var contentBorder = GetContentBorder(expander);
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 5, 0, 0));
|
|
|
|
expander.IsBorderless = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
frame.BorderThickness.ShouldBe(default(Thickness));
|
|
contentBorder.BorderThickness.ShouldBe(default(Thickness));
|
|
|
|
expander.IsBorderless = false;
|
|
Dispatcher.UIThread.RunJobs();
|
|
frame.BorderThickness.ShouldBe(new Thickness(5));
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 5, 0, 0));
|
|
|
|
expander.IsGhostStyle = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
frame.BorderThickness.ShouldBe(default(Thickness));
|
|
contentBorder.BorderThickness.ShouldBe(default(Thickness));
|
|
|
|
expander.IsGhostStyle = false;
|
|
Dispatcher.UIThread.RunJobs();
|
|
frame.BorderThickness.ShouldBe(new Thickness(5));
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 5, 0, 0));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Pixel_Aligned_Frame_And_Content_Separator_Apply_Theme_Brushes()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
IsExpanded = true,
|
|
IsMotionEnabled = false,
|
|
BorderThickness = new Thickness(1)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
window.SetRenderScaling(1.5);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var borderBrush = GetThemeResource<IBrush>(SharedTokenKind.ColorBorder);
|
|
var frame = FindVisualByName<PixelAlignedBorder>(expander, "PART_Frame");
|
|
frame.ShouldNotBeNull();
|
|
frame!.BorderBrush.ShouldNotBeNull();
|
|
BrushShouldHaveSameColor(frame.BorderBrush, borderBrush);
|
|
DrawingGroupHasPenWithBrush(RenderToDrawingGroup(frame), borderBrush)
|
|
.ShouldBeTrue("the expander frame must render a visible outer border");
|
|
|
|
var headerDecorator = GetHeaderDecorator(expander);
|
|
headerDecorator.Background.ShouldNotBeNull();
|
|
headerDecorator.BorderThickness.ShouldBe(default(Thickness));
|
|
|
|
var contentBorder = GetContentBorder(expander);
|
|
contentBorder.BorderBrush.ShouldNotBeNull();
|
|
BrushShouldHaveSameColor(contentBorder.BorderBrush, borderBrush);
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 1, 0, 0));
|
|
DrawingGroupHasFillWithBrush(RenderToDrawingGroup(contentBorder), borderBrush)
|
|
.ShouldBeTrue("the expanded expander content must render the separator line");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 回归:<c>PART_Frame</c> 自己画圆角,而 header/body 是它的子节点且各自带背景。
|
|
/// <c>ClipToBounds</c> 只做矩形裁剪、不做圆角裁剪,所以模板必须开启
|
|
/// <c>ClipContentToCornerRadius</c>,否则子节点的不透明背景会平铺到方形边界、
|
|
/// 把圆角覆盖掉(默认主题的 HeaderBg alpha≈2%,缺陷几乎不可见;
|
|
/// 一旦用户通过 Semantic Part 或 <c>IsGhostStyle</c> 设不透明背景就显形)。
|
|
/// <para>
|
|
/// 这里只能断言模板契约与几何前置条件:headless 平台的几何包含性无法表示圆角图形,
|
|
/// <c>DashedBorder.UpdateClip</c> 会按设计降级为不应用(见
|
|
/// <c>DashedBorderClipContentTests.Clip_Is_Not_Applied_When_The_Platform_Cannot_Hit_Test_The_Rounded_Figure</c>),
|
|
/// 因此 <c>Child.Clip</c> 在测试宿主里始终为 null,无法直接观测。真实 Skia 后端会走裁剪路径。
|
|
/// </para>
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(false, false, false)]
|
|
[InlineData(true, false, false)]
|
|
[InlineData(false, true, false)]
|
|
[InlineData(false, false, true)]
|
|
[InlineData(true, false, true)]
|
|
public void Frame_Enables_Content_Clip_To_Corner_Radius(
|
|
bool isExpanded,
|
|
bool isGhostStyle,
|
|
bool isBorderless)
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
IsExpanded = isExpanded,
|
|
IsGhostStyle = isGhostStyle,
|
|
IsBorderless = isBorderless,
|
|
IsMotionEnabled = false
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var frame = FindVisualByName<PixelAlignedBorder>(expander, "PART_Frame");
|
|
frame.ShouldNotBeNull();
|
|
|
|
frame!.ClipContentToCornerRadius.ShouldBeTrue(
|
|
"PART_Frame must clip its content to the rounded corner; without it the header " +
|
|
"background squares off the top corners");
|
|
frame.ClipToBounds.ShouldBeTrue();
|
|
frame.CornerRadius.TopLeft.ShouldBeGreaterThan(0);
|
|
|
|
// header 的左上角必须落在 frame 的圆角方形区域内,否则这条裁剪就没有存在意义
|
|
// (说明圆角位置没有内容覆盖)。二者一起构成"不开启就会露出方角"的证据。
|
|
var headerOrigin = GetHeaderDecorator(expander).TranslatePoint(new Point(), frame);
|
|
headerOrigin.ShouldNotBeNull();
|
|
headerOrigin!.Value.X.ShouldBeLessThan(frame.CornerRadius.TopLeft);
|
|
headerOrigin.Value.Y.ShouldBeLessThan(frame.CornerRadius.TopLeft);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// frame 的圆角与边框宽度由主题 Style 在模板实例化之后写入,而不是 owner 属性投影。
|
|
/// 裁剪必须在这些值落地后重算,因此这里验证它们在运行期确实已生效且非退化。
|
|
/// </summary>
|
|
[Fact]
|
|
public void Frame_Corner_Radius_Comes_From_The_Theme_And_Stays_Non_Degenerate()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
IsExpanded = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var frame = FindVisualByName<PixelAlignedBorder>(expander, "PART_Frame");
|
|
frame.ShouldNotBeNull();
|
|
|
|
// ExpanderBorderRadius = BorderRadiusLG(见 ExpanderToken.CalculateTokenValues)。
|
|
var expectedRadius = GetThemeResource<CornerRadius>(SharedTokenKind.BorderRadiusLG);
|
|
frame!.CornerRadius.ShouldBe(expectedRadius);
|
|
expectedRadius.TopLeft.ShouldBeGreaterThan(0);
|
|
|
|
// 圆角是 Style 写入的,运行期改动必须能反映到节点上(裁剪跟随该属性重算)。
|
|
frame.CornerRadius = new CornerRadius(16);
|
|
Dispatcher.UIThread.RunJobs();
|
|
frame.CornerRadius.TopLeft.ShouldBe(16);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ExpandDirection.Down, 0, 1, 0, 0)]
|
|
[InlineData(ExpandDirection.Up, 0, 0, 0, 1)]
|
|
[InlineData(ExpandDirection.Left, 0, 0, 1, 0)]
|
|
[InlineData(ExpandDirection.Right, 1, 0, 0, 0)]
|
|
public void Content_Separator_Is_Owned_By_Content_And_Follows_Direction(
|
|
ExpandDirection direction,
|
|
double left,
|
|
double top,
|
|
double right,
|
|
double bottom)
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
ExpandDirection = direction,
|
|
IsExpanded = true,
|
|
IsMotionEnabled = false,
|
|
BorderThickness = new Thickness(1)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
GetHeaderDecorator(expander).BorderThickness.ShouldBe(default(Thickness));
|
|
GetContentBorder(expander).BorderThickness.ShouldBe(
|
|
new Thickness(left, top, right, bottom));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ExpandDirection.Down)]
|
|
[InlineData(ExpandDirection.Up)]
|
|
[InlineData(ExpandDirection.Left)]
|
|
[InlineData(ExpandDirection.Right)]
|
|
public void Content_Separator_Remains_Adjacent_To_Header_In_Every_Direction(
|
|
ExpandDirection direction)
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = new Border
|
|
{
|
|
Width = 70,
|
|
Height = 24
|
|
},
|
|
Content = new Border
|
|
{
|
|
Width = 80,
|
|
Height = 40
|
|
},
|
|
HeaderPadding = default,
|
|
ContentPadding = default,
|
|
ExpandDirection = direction,
|
|
IsExpanded = true,
|
|
IsShowExpandIcon = false,
|
|
IsMotionEnabled = false,
|
|
BorderThickness = new Thickness(1)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var mainLayout = FindVisualByName<DockPanel>(expander, "PART_MainLayout");
|
|
mainLayout.ShouldNotBeNull();
|
|
|
|
var headerLayoutTransform =
|
|
FindVisualByName<Control>(expander, "PART_HeaderLayoutTransform");
|
|
headerLayoutTransform.ShouldNotBeNull();
|
|
|
|
var mainBounds = new Rect(mainLayout!.Bounds.Size);
|
|
var headerBounds = GetPhysicalBounds(headerLayoutTransform!, mainLayout);
|
|
var contentBounds = GetPhysicalBounds(GetContentBorder(expander), mainLayout);
|
|
|
|
AssertContentAndHeaderArrangement(direction, mainBounds, headerBounds, contentBounds);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Content_Separator_Moves_To_Mapped_Edge_When_Direction_Changes()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
ExpandDirection = ExpandDirection.Down,
|
|
IsExpanded = true,
|
|
IsMotionEnabled = false,
|
|
BorderThickness = new Thickness(2)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var contentBorder = GetContentBorder(expander);
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 2, 0, 0));
|
|
|
|
expander.ExpandDirection = ExpandDirection.Up;
|
|
Dispatcher.UIThread.RunJobs();
|
|
ReferenceEquals(GetContentBorder(expander), contentBorder).ShouldBeTrue();
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 0, 0, 2));
|
|
|
|
expander.ExpandDirection = ExpandDirection.Left;
|
|
Dispatcher.UIThread.RunJobs();
|
|
ReferenceEquals(GetContentBorder(expander), contentBorder).ShouldBeTrue();
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 0, 2, 0));
|
|
|
|
expander.ExpandDirection = ExpandDirection.Right;
|
|
Dispatcher.UIThread.RunJobs();
|
|
ReferenceEquals(GetContentBorder(expander), contentBorder).ShouldBeTrue();
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(2, 0, 0, 0));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Runtime_Asymmetric_Border_Thickness_Uses_Bottom_For_Current_Direction_Edge()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
ExpandDirection = ExpandDirection.Left,
|
|
IsExpanded = true,
|
|
IsMotionEnabled = false,
|
|
BorderThickness = new Thickness(1, 2, 3, 4)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var contentBorder = GetContentBorder(expander);
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 0, 4, 0));
|
|
|
|
expander.BorderThickness = new Thickness(5, 6, 7, 8);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
contentBorder.BorderThickness.ShouldBe(new Thickness(0, 0, 8, 0));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_Trigger_Does_Not_Toggle_From_Header_Click()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
TriggerType = ExpanderTriggerType.Icon,
|
|
IsMotionEnabled = false
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
Click(GetHeaderDecorator(expander), window);
|
|
expander.IsExpanded.ShouldBeFalse();
|
|
|
|
var expandButton = GetExpandButton(expander);
|
|
expandButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent, expandButton));
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
expander.IsExpanded.ShouldBeTrue();
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Content_Motion_Reversal_Does_Not_Change_Separator_And_Uses_Latest_Expanded_State()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
IsMotionEnabled = true,
|
|
MotionDuration = TimeSpan.FromMilliseconds(100),
|
|
BorderThickness = new Thickness(2)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var motionActor = GetContentMotionActor(expander);
|
|
var initialContentBorderThickness = expander.ContentBorderThickness;
|
|
var contentBorderChangeCount = 0;
|
|
using var contentBorderSubscription =
|
|
expander.GetObservable(AtomUIExpander.ContentBorderThicknessProperty)
|
|
.Subscribe(_ => contentBorderChangeCount++);
|
|
|
|
contentBorderChangeCount = 0;
|
|
|
|
expander.IsExpanded = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
motionActor.IsAnimating(Visual.OpacityProperty).ShouldBeTrue();
|
|
|
|
expander.IsExpanded = false;
|
|
Dispatcher.UIThread.RunJobs();
|
|
motionActor.IsAnimating(Visual.OpacityProperty).ShouldBeTrue();
|
|
|
|
expander.IsExpanded = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
WaitForLayoutCondition(() => motionActor.IsVisible &&
|
|
!motionActor.IsAnimating(Visual.OpacityProperty) &&
|
|
motionActor.Opacity == 1);
|
|
|
|
expander.IsExpanded.ShouldBeTrue();
|
|
motionActor.IsVisible.ShouldBe(expander.IsExpanded);
|
|
motionActor.IsAnimating(Visual.OpacityProperty).ShouldBeFalse();
|
|
motionActor.MotionTransform.ShouldBeNull();
|
|
motionActor.Transitions.ShouldBeNull();
|
|
contentBorderChangeCount.ShouldBe(0);
|
|
expander.ContentBorderThickness.ShouldBe(initialContentBorderThickness);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Template_Reapply_Unsubscribes_Old_Expand_Button()
|
|
{
|
|
var expander = new TestExpander { IsMotionEnabled = false };
|
|
var oldButton = new IconButton();
|
|
var newButton = new IconButton();
|
|
|
|
expander.ApplyTemplateParts(oldButton, new LayoutAwareMotionActor());
|
|
expander.ApplyTemplateParts(newButton, new LayoutAwareMotionActor());
|
|
|
|
oldButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent, oldButton));
|
|
expander.IsExpanded.ShouldBeFalse();
|
|
|
|
newButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent, newButton));
|
|
expander.IsExpanded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Template_Reapply_Cancels_Old_Content_Motion_And_Preserves_User_Local_Values()
|
|
{
|
|
var expander = new TestExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
IsMotionEnabled = true,
|
|
MotionDuration = TimeSpan.FromMilliseconds(100)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
expander.IsExpanded = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var oldMotionActor = GetContentMotionActor(expander);
|
|
oldMotionActor.IsAnimating(Visual.OpacityProperty).ShouldBeTrue();
|
|
oldMotionActor.MotionTransformOperations = TransformOperations.Identity;
|
|
oldMotionActor.Height = 42;
|
|
oldMotionActor.MotionTransformOperations.ShouldNotBeNull();
|
|
oldMotionActor.Height.ShouldBe(42);
|
|
var lateCompletionCount = 0;
|
|
oldMotionActor.Completed += (_, _) => lateCompletionCount++;
|
|
|
|
expander.ApplyTemplateParts(new IconButton(), new LayoutAwareMotionActor());
|
|
|
|
oldMotionActor.IsAnimating(Visual.OpacityProperty).ShouldBeFalse();
|
|
oldMotionActor.IsVisible.ShouldBeTrue();
|
|
oldMotionActor.Opacity.ShouldBe(1);
|
|
oldMotionActor.MotionTransform.ShouldBeNull();
|
|
oldMotionActor.MotionTransformOperations.ShouldBe(TransformOperations.Identity);
|
|
oldMotionActor.Transitions.ShouldBeNull();
|
|
oldMotionActor.Height.ShouldBe(42);
|
|
|
|
Thread.Sleep(expander.MotionDuration + TimeSpan.FromMilliseconds(50));
|
|
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
oldMotionActor.IsAnimating(Visual.OpacityProperty).ShouldBeFalse();
|
|
oldMotionActor.IsVisible.ShouldBeTrue();
|
|
oldMotionActor.Opacity.ShouldBe(1);
|
|
oldMotionActor.MotionTransform.ShouldBeNull();
|
|
oldMotionActor.MotionTransformOperations.ShouldBe(TransformOperations.Identity);
|
|
oldMotionActor.Transitions.ShouldBeNull();
|
|
oldMotionActor.Height.ShouldBe(42);
|
|
lateCompletionCount.ShouldBe(0);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Detach_Cancels_Active_Content_Motion_And_Preserves_User_Local_Values()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
IsMotionEnabled = true,
|
|
MotionDuration = TimeSpan.FromMilliseconds(100)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
expander.IsExpanded = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var motionActor = GetContentMotionActor(expander);
|
|
motionActor.IsAnimating(Visual.OpacityProperty).ShouldBeTrue();
|
|
motionActor.MotionTransformOperations = TransformOperations.Identity;
|
|
motionActor.Height = 42;
|
|
motionActor.MotionTransformOperations.ShouldNotBeNull();
|
|
motionActor.Height.ShouldBe(42);
|
|
var lateCompletionCount = 0;
|
|
motionActor.Completed += (_, _) => lateCompletionCount++;
|
|
|
|
window.Content = null;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
motionActor.IsAnimating(Visual.OpacityProperty).ShouldBeFalse();
|
|
motionActor.IsVisible.ShouldBeTrue();
|
|
motionActor.Opacity.ShouldBe(1);
|
|
motionActor.MotionTransform.ShouldBeNull();
|
|
motionActor.MotionTransformOperations.ShouldBe(TransformOperations.Identity);
|
|
motionActor.Transitions.ShouldBeNull();
|
|
motionActor.Height.ShouldBe(42);
|
|
|
|
Thread.Sleep(expander.MotionDuration + TimeSpan.FromMilliseconds(50));
|
|
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
motionActor.IsAnimating(Visual.OpacityProperty).ShouldBeFalse();
|
|
motionActor.IsVisible.ShouldBeTrue();
|
|
motionActor.Opacity.ShouldBe(1);
|
|
motionActor.MotionTransform.ShouldBeNull();
|
|
motionActor.MotionTransformOperations.ShouldBe(TransformOperations.Identity);
|
|
motionActor.Transitions.ShouldBeNull();
|
|
motionActor.Height.ShouldBe(42);
|
|
lateCompletionCount.ShouldBe(0);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Collapse_Detach_And_Reattach_Applies_Current_Collapsed_Stable_State()
|
|
{
|
|
var expander = new AtomUIExpander
|
|
{
|
|
Header = "Header",
|
|
Content = "Content",
|
|
IsExpanded = true,
|
|
IsMotionEnabled = true,
|
|
MotionDuration = TimeSpan.FromMilliseconds(100)
|
|
};
|
|
|
|
var window = ShowInWindow(expander);
|
|
try
|
|
{
|
|
var motionActor = GetContentMotionActor(expander);
|
|
motionActor.IsVisible.ShouldBeTrue();
|
|
|
|
expander.IsExpanded = false;
|
|
Dispatcher.UIThread.RunJobs();
|
|
motionActor.IsVisible.ShouldBeTrue();
|
|
motionActor.IsAnimating(Visual.OpacityProperty).ShouldBeTrue();
|
|
|
|
window.Content = null;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
expander.IsExpanded.ShouldBeFalse();
|
|
motionActor.IsVisible.ShouldBeFalse();
|
|
motionActor.Opacity.ShouldBe(0);
|
|
|
|
window.Content = expander;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
ReferenceEquals(GetContentMotionActor(expander), motionActor).ShouldBeTrue();
|
|
motionActor.IsVisible.ShouldBe(expander.IsExpanded);
|
|
motionActor.Opacity.ShouldBe(0);
|
|
|
|
Thread.Sleep(expander.MotionDuration + TimeSpan.FromMilliseconds(50));
|
|
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
motionActor.IsVisible.ShouldBe(expander.IsExpanded);
|
|
motionActor.Opacity.ShouldBe(0);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
private static AvaloniaWindow ShowInWindow(Control content)
|
|
{
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 360,
|
|
Height = 220,
|
|
Content = content
|
|
};
|
|
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
return window;
|
|
}
|
|
|
|
private static void Click(Control control, AvaloniaWindow window)
|
|
{
|
|
var point = control.TranslatePoint(
|
|
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2),
|
|
window);
|
|
|
|
point.ShouldNotBeNull();
|
|
window.MouseMove(point.Value);
|
|
window.MouseDown(point.Value, MouseButton.Left);
|
|
window.MouseUp(point.Value, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static PixelAlignedBorder GetHeaderDecorator(AtomUIExpander expander)
|
|
{
|
|
var header = FindVisualByName<PixelAlignedBorder>(expander, "PART_HeaderDecorator");
|
|
header.ShouldNotBeNull();
|
|
return header!;
|
|
}
|
|
|
|
private static IconButton GetExpandButton(AtomUIExpander expander)
|
|
{
|
|
var button = FindTemplatePart<IconButton>(expander, "PART_ExpandButton");
|
|
button.ShouldNotBeNull();
|
|
return button!;
|
|
}
|
|
|
|
private static ContentPresenter GetHeaderPresenter(AtomUIExpander expander)
|
|
{
|
|
var presenter = FindTemplatePart<ContentPresenter>(expander, "PART_HeaderPresenter");
|
|
presenter.ShouldNotBeNull();
|
|
return presenter!;
|
|
}
|
|
|
|
private static BaseMotionActor GetContentMotionActor(AtomUIExpander expander)
|
|
{
|
|
var motionActor = FindTemplatePart<BaseMotionActor>(expander, "PART_ContentMotionActor");
|
|
motionActor.ShouldNotBeNull();
|
|
return motionActor!;
|
|
}
|
|
|
|
private static PixelAlignedBorder GetContentBorder(AtomUIExpander expander)
|
|
{
|
|
var motionActor = GetContentMotionActor(expander);
|
|
motionActor.Content.ShouldBeOfType<PixelAlignedBorder>();
|
|
var contentBorder = (PixelAlignedBorder)motionActor.Content!;
|
|
string.IsNullOrEmpty(contentBorder.Name).ShouldBeTrue();
|
|
return contentBorder;
|
|
}
|
|
|
|
private static void AssertContentAndHeaderArrangement(ExpandDirection direction,
|
|
Rect mainBounds,
|
|
Rect headerBounds,
|
|
Rect contentBounds)
|
|
{
|
|
contentBounds.Width.ShouldBeGreaterThan(0);
|
|
contentBounds.Height.ShouldBeGreaterThan(0);
|
|
|
|
if (direction is ExpandDirection.Down or ExpandDirection.Up)
|
|
{
|
|
headerBounds.Left.ShouldBe(mainBounds.Left, LayoutTolerance);
|
|
headerBounds.Right.ShouldBe(mainBounds.Right, LayoutTolerance);
|
|
contentBounds.Left.ShouldBe(mainBounds.Left, LayoutTolerance);
|
|
contentBounds.Right.ShouldBe(mainBounds.Right, LayoutTolerance);
|
|
|
|
if (direction == ExpandDirection.Down)
|
|
{
|
|
headerBounds.Top.ShouldBe(mainBounds.Top, LayoutTolerance);
|
|
contentBounds.Top.ShouldBe(headerBounds.Bottom, LayoutTolerance);
|
|
contentBounds.Bottom.ShouldBe(mainBounds.Bottom, LayoutTolerance);
|
|
}
|
|
else
|
|
{
|
|
contentBounds.Top.ShouldBe(mainBounds.Top, LayoutTolerance);
|
|
contentBounds.Bottom.ShouldBe(headerBounds.Top, LayoutTolerance);
|
|
headerBounds.Bottom.ShouldBe(mainBounds.Bottom, LayoutTolerance);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
headerBounds.Top.ShouldBe(mainBounds.Top, LayoutTolerance);
|
|
headerBounds.Bottom.ShouldBe(mainBounds.Bottom, LayoutTolerance);
|
|
contentBounds.Top.ShouldBe(mainBounds.Top, LayoutTolerance);
|
|
contentBounds.Bottom.ShouldBe(mainBounds.Bottom, LayoutTolerance);
|
|
|
|
if (direction == ExpandDirection.Left)
|
|
{
|
|
contentBounds.Left.ShouldBe(mainBounds.Left, LayoutTolerance);
|
|
contentBounds.Right.ShouldBe(headerBounds.Left, LayoutTolerance);
|
|
headerBounds.Right.ShouldBe(mainBounds.Right, LayoutTolerance);
|
|
}
|
|
else
|
|
{
|
|
headerBounds.Left.ShouldBe(mainBounds.Left, LayoutTolerance);
|
|
contentBounds.Left.ShouldBe(headerBounds.Right, LayoutTolerance);
|
|
contentBounds.Right.ShouldBe(mainBounds.Right, LayoutTolerance);
|
|
}
|
|
}
|
|
|
|
private static Rect GetPhysicalBounds(Control control, Visual relativeTo)
|
|
{
|
|
Point[] corners =
|
|
[
|
|
new Point(),
|
|
new Point(control.Bounds.Width, 0),
|
|
new Point(0, control.Bounds.Height),
|
|
new Point(control.Bounds.Width, control.Bounds.Height)
|
|
];
|
|
|
|
var translatedCorners = corners.Select(corner =>
|
|
{
|
|
var translated = control.TranslatePoint(corner, relativeTo);
|
|
translated.ShouldNotBeNull();
|
|
return translated.Value;
|
|
}).ToArray();
|
|
|
|
var left = translatedCorners.Min(point => point.X);
|
|
var top = translatedCorners.Min(point => point.Y);
|
|
var right = translatedCorners.Max(point => point.X);
|
|
var bottom = translatedCorners.Max(point => point.Y);
|
|
return new Rect(left, top, right - left, bottom - top);
|
|
}
|
|
|
|
private static void WaitForLayoutCondition(Func<bool> condition, int timeoutMilliseconds = 500)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMilliseconds);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
|
Dispatcher.UIThread.RunJobs();
|
|
if (condition())
|
|
{
|
|
return;
|
|
}
|
|
|
|
Thread.Sleep(10);
|
|
}
|
|
|
|
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
|
Dispatcher.UIThread.RunJobs();
|
|
condition().ShouldBeTrue("The expected layout or animation state did not settle before the timeout.");
|
|
}
|
|
|
|
private static T? FindTemplatePart<T>(AtomUIExpander expander, string name)
|
|
where T : Control
|
|
{
|
|
return expander.GetSelfAndVisualDescendants()
|
|
.OfType<T>()
|
|
.FirstOrDefault(control => control.Name == name &&
|
|
ReferenceEquals(control.TemplatedParent, expander));
|
|
}
|
|
|
|
private static T? FindVisualByName<T>(Control root, string name)
|
|
where T : Control
|
|
{
|
|
return root.GetSelfAndVisualDescendants()
|
|
.OfType<T>()
|
|
.FirstOrDefault(control => control.Name == name);
|
|
}
|
|
|
|
private static double GetHorizontalGap(Control left, Control right, Visual relativeTo)
|
|
{
|
|
var leftOrigin = GetRelativeOrigin(left, relativeTo);
|
|
var rightOrigin = GetRelativeOrigin(right, relativeTo);
|
|
return rightOrigin.X - (leftOrigin.X + left.Bounds.Width);
|
|
}
|
|
|
|
private static double GetCenterY(Control control, Visual relativeTo)
|
|
{
|
|
var origin = GetRelativeOrigin(control, relativeTo);
|
|
return origin.Y + control.Bounds.Height / 2;
|
|
}
|
|
|
|
private static Point GetRelativeOrigin(Control control, Visual relativeTo)
|
|
{
|
|
var origin = control.TranslatePoint(new Point(), relativeTo);
|
|
origin.ShouldNotBeNull();
|
|
return origin.Value;
|
|
}
|
|
|
|
private static DrawingGroup RenderToDrawingGroup(Control control)
|
|
{
|
|
var drawingGroup = new DrawingGroup();
|
|
using var context = drawingGroup.Open();
|
|
control.Render(context);
|
|
return drawingGroup;
|
|
}
|
|
|
|
private static bool DrawingGroupHasPenWithBrush(DrawingGroup drawingGroup, IBrush expectedBrush)
|
|
{
|
|
return EnumerateGeometryDrawings(drawingGroup)
|
|
.Any(drawing => drawing.Pen is { Brush: { } brush } &&
|
|
BrushesHaveSameColor(brush, expectedBrush));
|
|
}
|
|
|
|
private static bool DrawingGroupHasFillWithBrush(DrawingGroup drawingGroup, IBrush expectedBrush)
|
|
{
|
|
return EnumerateGeometryDrawings(drawingGroup)
|
|
.Any(drawing => drawing.Brush is { } brush &&
|
|
BrushesHaveSameColor(brush, expectedBrush));
|
|
}
|
|
|
|
private static IEnumerable<GeometryDrawing> EnumerateGeometryDrawings(Drawing drawing)
|
|
{
|
|
if (drawing is GeometryDrawing geometryDrawing)
|
|
{
|
|
yield return geometryDrawing;
|
|
}
|
|
else if (drawing is DrawingGroup drawingGroup)
|
|
{
|
|
foreach (var child in drawingGroup.Children.SelectMany(EnumerateGeometryDrawings))
|
|
{
|
|
yield return child;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static T GetThemeResource<T>(object key)
|
|
{
|
|
var application = Application.Current;
|
|
application.ShouldNotBeNull();
|
|
application!.TryGetResource(key, application.ActualThemeVariant, out var value).ShouldBeTrue();
|
|
value.ShouldBeAssignableTo<T>();
|
|
return (T)value!;
|
|
}
|
|
|
|
private static void BrushShouldHaveSameColor(IBrush? actual, IBrush? expected)
|
|
{
|
|
BrushesHaveSameColor(actual, expected).ShouldBeTrue();
|
|
}
|
|
|
|
private static bool BrushesHaveSameColor(IBrush? actual, IBrush? expected)
|
|
{
|
|
return GetSolidBrushColor(actual) == GetSolidBrushColor(expected);
|
|
}
|
|
|
|
private static Color GetSolidBrushColor(IBrush? brush)
|
|
{
|
|
brush.ShouldNotBeNull();
|
|
brush.ShouldBeAssignableTo<ISolidColorBrush>();
|
|
return ((ISolidColorBrush)brush!).Color;
|
|
}
|
|
|
|
private sealed class TestExpander : AtomUIExpander
|
|
{
|
|
protected override Type StyleKeyOverride => typeof(AtomUIExpander);
|
|
|
|
public void ApplyTemplateParts(IconButton expandButton, BaseMotionActor motionActor)
|
|
{
|
|
var nameScope = new NameScope();
|
|
nameScope.Register("PART_ExpandButton", expandButton);
|
|
nameScope.Register("PART_ContentMotionActor", motionActor);
|
|
|
|
OnApplyTemplate(new TemplateAppliedEventArgs(nameScope));
|
|
}
|
|
}
|
|
}
|