mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-19 06:58:57 +08:00
470 lines
16 KiB
C#
470 lines
16 KiB
C#
using System.Reflection;
|
|
using AtomUI.MotionScene;
|
|
using Avalonia;
|
|
using Avalonia.Animation;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Layout;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.NavMenu;
|
|
|
|
public class NavMenuInlineMotionTests
|
|
{
|
|
static NavMenuInlineMotionTests() => AvaloniaTestApp.EnsureInitialized();
|
|
|
|
[Fact]
|
|
public void Accordion_Closes_Previous_Branch_When_The_Next_Branch_Starts_Opening()
|
|
{
|
|
using var scene = new MenuScene();
|
|
scene.OpenWithoutMotion(0);
|
|
scene.EnableMotion();
|
|
|
|
scene.ClickHeader(1);
|
|
|
|
scene.Items[0].IsSubMenuOpen.ShouldBeFalse(
|
|
"Accordion exclusivity must be applied before waiting for the new branch's animation.");
|
|
scene.Items[1].IsSubMenuOpen.ShouldBeTrue();
|
|
scene.Actors[0].IsVisible.ShouldBeTrue("The old branch remains visible while it collapses.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Opening_And_Closing_Animate_The_Full_Height_Without_Jumping_The_Following_Content()
|
|
{
|
|
using var scene = new MenuScene();
|
|
var collapsedTop = scene.Footer.Bounds.Top;
|
|
scene.OpenWithoutMotion(0);
|
|
var expandedTop = scene.Footer.Bounds.Top;
|
|
scene.Items[0].IsSubMenuOpen = false;
|
|
scene.Pump();
|
|
scene.EnableMotion();
|
|
|
|
scene.ClickHeader(0);
|
|
scene.Step(0);
|
|
scene.Footer.Bounds.Top.ShouldBe(collapsedTop, 1,
|
|
"Opening must begin at zero height, without reserving 80% of the content immediately.");
|
|
scene.Step(500);
|
|
scene.Footer.Bounds.Top.ShouldBeGreaterThan(collapsedTop + 1);
|
|
scene.Footer.Bounds.Top.ShouldBeLessThan(expandedTop - 1);
|
|
scene.Step(1100);
|
|
scene.Footer.Bounds.Top.ShouldBe(expandedTop, 1);
|
|
|
|
scene.ClickHeader(0);
|
|
scene.Step(1100);
|
|
scene.Step(1600);
|
|
scene.Footer.Bounds.Top.ShouldBeLessThan(expandedTop - (expandedTop - collapsedTop) * 0.25,
|
|
"Closing must traverse the full height instead of stopping at 80% and then hiding.");
|
|
scene.Step(2200);
|
|
scene.Footer.Bounds.Top.ShouldBe(collapsedTop, 1);
|
|
scene.Actors[0].IsVisible.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Switching_Equal_Height_Accordion_Branches_Keeps_The_Footer_Stationary()
|
|
{
|
|
using var scene = new MenuScene();
|
|
scene.OpenWithoutMotion(0);
|
|
var footerTop = scene.Footer.Bounds.Top;
|
|
scene.EnableMotion();
|
|
|
|
scene.ClickHeader(1);
|
|
for (var milliseconds = 0; milliseconds <= 1200; milliseconds += 100)
|
|
{
|
|
scene.Step(milliseconds);
|
|
scene.Footer.Bounds.Top.ShouldBe(footerTop, 1,
|
|
$"Equal-height branches must exchange space concurrently (frame {milliseconds} ms).");
|
|
}
|
|
|
|
scene.Actors[0].IsVisible.ShouldBeFalse();
|
|
scene.Actors[1].IsVisible.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Reversing_A_Closing_Branch_Uses_Its_Current_Height_And_Honors_The_Latest_Click()
|
|
{
|
|
using var scene = new MenuScene();
|
|
scene.OpenWithoutMotion(0);
|
|
var expandedTop = scene.Footer.Bounds.Top;
|
|
scene.EnableMotion();
|
|
scene.ClickHeader(0);
|
|
scene.Step(0);
|
|
scene.Step(400);
|
|
var interruptedTop = scene.Footer.Bounds.Top;
|
|
interruptedTop.ShouldBeLessThan(expandedTop);
|
|
|
|
scene.ClickHeader(0);
|
|
|
|
scene.Items[0].IsSubMenuOpen.ShouldBeTrue("A click during collapse must reverse the motion.");
|
|
scene.Footer.Bounds.Top.ShouldBe(interruptedTop, 1, "Reversal must also preserve layout before its first clock tick.");
|
|
scene.Step(400);
|
|
scene.Footer.Bounds.Top.ShouldBe(interruptedTop, 1, "Reversal must resume from the rendered height.");
|
|
scene.Step(1600);
|
|
scene.Items[0].IsSubMenuOpen.ShouldBeTrue();
|
|
scene.Actors[0].IsVisible.ShouldBeTrue();
|
|
scene.Actors[0].Opacity.ShouldBe(1);
|
|
scene.Footer.Bounds.Top.ShouldBe(expandedTop, 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Rapid_Accordion_Switches_Preserve_The_Current_Layout_And_Last_Requested_Branch()
|
|
{
|
|
using var scene = new MenuScene();
|
|
scene.OpenWithoutMotion(0);
|
|
var stableTop = scene.Footer.Bounds.Top;
|
|
scene.EnableMotion();
|
|
scene.ClickHeader(1);
|
|
scene.Step(0);
|
|
scene.Step(200);
|
|
var interruptedTop = scene.Footer.Bounds.Top;
|
|
|
|
scene.ClickHeader(2);
|
|
scene.Step(200);
|
|
|
|
scene.Footer.Bounds.Top.ShouldBe(interruptedTop, 1);
|
|
scene.Items.Select(item => item.IsSubMenuOpen).ShouldBe([false, false, true]);
|
|
scene.Step(1500);
|
|
scene.Actors.Select(actor => actor.IsVisible).ShouldBe([false, false, true]);
|
|
scene.Footer.Bounds.Top.ShouldBe(stableTop, 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cancelled_Close_Does_Not_Raise_A_Stale_Closed_Event()
|
|
{
|
|
using var scene = new MenuScene();
|
|
scene.OpenWithoutMotion(0);
|
|
scene.EnableMotion();
|
|
var closed = 0;
|
|
scene.Items[0].SubmenuClosed += (_, _) => closed++;
|
|
scene.ClickHeader(0);
|
|
scene.Step(0);
|
|
scene.Step(300);
|
|
scene.ClickHeader(0);
|
|
scene.Step(300);
|
|
scene.Step(1500);
|
|
|
|
closed.ShouldBe(0);
|
|
scene.Items[0].IsSubMenuOpen.ShouldBeTrue();
|
|
scene.Actors[0].IsVisible.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Disabling_Motion_During_Collapse_Immediately_Applies_The_Closed_State()
|
|
{
|
|
using var scene = new MenuScene();
|
|
var collapsedTop = scene.Footer.Bounds.Top;
|
|
scene.OpenWithoutMotion(0);
|
|
scene.EnableMotion();
|
|
scene.ClickHeader(0);
|
|
scene.Step(0);
|
|
scene.Step(300);
|
|
|
|
scene.DisableMotion();
|
|
scene.Pump();
|
|
|
|
scene.Actors[0].IsVisible.ShouldBeFalse();
|
|
double.IsNaN(scene.Actors[0].Height).ShouldBeTrue();
|
|
scene.Footer.Bounds.Top.ShouldBe(collapsedTop, 1);
|
|
scene.Step(1500);
|
|
scene.Footer.Bounds.Top.ShouldBe(collapsedTop, 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Detaching_During_Motion_Clears_Animated_Height_And_Reattaches_In_The_Requested_State()
|
|
{
|
|
using var scene = new MenuScene();
|
|
scene.EnableMotion();
|
|
scene.ClickHeader(0);
|
|
scene.Step(0);
|
|
scene.Step(300);
|
|
var actor = scene.Actors[0];
|
|
|
|
scene.Detach();
|
|
|
|
double.IsNaN(actor.Height).ShouldBeTrue();
|
|
scene.Step(1500);
|
|
scene.Reattach();
|
|
scene.Items[0].IsSubMenuOpen.ShouldBeTrue();
|
|
actor.IsVisible.ShouldBeTrue();
|
|
actor.Opacity.ShouldBe(1);
|
|
double.IsNaN(actor.Height).ShouldBeTrue();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void Opening_Does_Not_Reserve_Expanded_Height_Before_The_First_Animation_Tick(double renderScaling)
|
|
{
|
|
using var scene = new MenuScene(renderScaling);
|
|
var collapsedTop = scene.Footer.Bounds.Top;
|
|
scene.EnableMotion();
|
|
|
|
scene.ClickHeader(0);
|
|
|
|
scene.Footer.Bounds.Top.ShouldBe(collapsedTop, 1,
|
|
"The render loop can draw before the animation clock's first tick.");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void Closing_Keeps_Expanded_Height_Before_The_First_Animation_Tick(double renderScaling)
|
|
{
|
|
using var scene = new MenuScene(renderScaling);
|
|
scene.OpenWithoutMotion(0);
|
|
var expandedTop = scene.Footer.Bounds.Top;
|
|
scene.EnableMotion();
|
|
|
|
scene.ClickHeader(0);
|
|
|
|
scene.Footer.Bounds.Top.ShouldBe(expandedTop, 1,
|
|
"Collapse must not snap shut while waiting for the first clock tick.");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void Opening_Holds_Its_Final_Frame_Before_Completion_Continuations_Run(double renderScaling)
|
|
{
|
|
using var scene = new MenuScene(renderScaling);
|
|
scene.OpenWithoutMotion(0);
|
|
var expandedTop = scene.Footer.Bounds.Top;
|
|
scene.Items[0].IsSubMenuOpen = false;
|
|
scene.Pump();
|
|
scene.EnableMotion();
|
|
scene.ClickHeader(0);
|
|
scene.Step(0);
|
|
scene.Step(990);
|
|
|
|
scene.StepBeforeContinuations(1100);
|
|
|
|
scene.Actors[0].Opacity.ShouldBe(1);
|
|
scene.Footer.Bounds.Top.ShouldBe(expandedTop, 1,
|
|
"The final frame must remain intact while asynchronous completion is pending.");
|
|
scene.Pump();
|
|
scene.Actors[0].Opacity.ShouldBe(1);
|
|
scene.Footer.Bounds.Top.ShouldBe(expandedTop, 1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void Closing_Reaches_Zero_Height_Before_Completion_Continuations_Run(double renderScaling)
|
|
{
|
|
using var scene = new MenuScene(renderScaling);
|
|
var collapsedTop = scene.Footer.Bounds.Top;
|
|
scene.OpenWithoutMotion(0);
|
|
scene.EnableMotion();
|
|
scene.ClickHeader(0);
|
|
scene.Step(0);
|
|
scene.Step(990);
|
|
|
|
scene.StepBeforeContinuations(1100);
|
|
|
|
scene.Footer.Bounds.Top.ShouldBe(collapsedTop, 1,
|
|
"The final clock tick must not restore expanded height or external margin before cleanup runs.");
|
|
scene.Actors[0].Opacity.ShouldBe(0);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void Reversing_Motion_Preserves_Opacity_Before_The_Next_Animation_Tick(double renderScaling)
|
|
{
|
|
using var scene = new MenuScene(renderScaling);
|
|
scene.OpenWithoutMotion(0);
|
|
scene.EnableMotion();
|
|
scene.ClickHeader(0);
|
|
scene.Step(0);
|
|
scene.Step(400);
|
|
var opacity = scene.Actors[0].Opacity;
|
|
|
|
scene.ClickHeader(0);
|
|
|
|
scene.Actors[0].Opacity.ShouldBe(opacity, 0.001);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void Child_Text_Keeps_Its_Position_Within_The_Clipped_Content_During_Motion(double renderScaling)
|
|
{
|
|
using var scene = new MenuScene(renderScaling);
|
|
scene.OpenWithoutMotion(0);
|
|
var actor = scene.Actors[0];
|
|
var text = actor.GetVisualDescendants().OfType<TextBlock>().First(block => block.Text == "Option 1");
|
|
var expandedPosition = text.TranslatePoint(default, actor)!.Value;
|
|
var expandedSize = text.Bounds.Size;
|
|
scene.EnableMotion();
|
|
scene.ClickHeader(0);
|
|
for (var milliseconds = 0; milliseconds < 1000; milliseconds += 16)
|
|
{
|
|
scene.Step(milliseconds);
|
|
text.TranslatePoint(default, actor)!.Value.ShouldBe(expandedPosition,
|
|
$"The text origin must stay fixed while only its viewport collapses ({milliseconds} ms).");
|
|
text.Bounds.Size.ShouldBe(expandedSize);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Queued_Same_Value_Request_Cannot_Report_A_Superseded_Completion()
|
|
{
|
|
using var scene = new MenuScene();
|
|
var opened = 0;
|
|
scene.Items[0].SubmenuOpened += (_, _) => opened++;
|
|
scene.Items[0].IsSubMenuOpen = true;
|
|
scene.Items[0].IsSubMenuOpen = false;
|
|
scene.Items[0].IsSubMenuOpen = true;
|
|
scene.Pump();
|
|
|
|
opened.ShouldBe(1);
|
|
scene.Items[0].IsSubMenuOpen.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Detached_Queued_Request_Cannot_Report_Completion()
|
|
{
|
|
using var scene = new MenuScene();
|
|
var opened = 0;
|
|
scene.Items[0].SubmenuOpened += (_, _) => opened++;
|
|
scene.Items[0].IsSubMenuOpen = true;
|
|
scene.Detach();
|
|
opened.ShouldBe(0);
|
|
scene.Reattach();
|
|
scene.Actors[0].IsVisible.ShouldBe(scene.Items[0].IsSubMenuOpen);
|
|
}
|
|
|
|
private sealed class MenuScene : IDisposable
|
|
{
|
|
private readonly ManualClock _clock = new();
|
|
private readonly Avalonia.Controls.Window _window;
|
|
private readonly AtomUI.Desktop.Controls.NavMenu _menu;
|
|
private object? _detachedContent;
|
|
|
|
public NavMenuItem[] Items { get; }
|
|
public BaseMotionActor[] Actors { get; }
|
|
public TextBlock Footer { get; } = new() { Text = "Accordion mode" };
|
|
|
|
public MenuScene(double renderScaling = 1)
|
|
{
|
|
_menu = new AtomUI.Desktop.Controls.NavMenu
|
|
{
|
|
Mode = NavMenuMode.Inline,
|
|
IsAccordionMode = true,
|
|
IsMotionEnabled = false,
|
|
Width = 300,
|
|
Margin = new Thickness(0, 0, 0, 20),
|
|
HorizontalAlignment = HorizontalAlignment.Left
|
|
};
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var node = new NavMenuNode { Header = $"Navigation {i + 1}" };
|
|
node.Children.Add(new NavMenuNode { Header = $"Option {i * 2 + 1}" });
|
|
node.Children.Add(new NavMenuNode { Header = $"Option {i * 2 + 2}" });
|
|
_menu.Items.Add(node);
|
|
}
|
|
|
|
_window = new Avalonia.Controls.Window
|
|
{
|
|
Width = 480,
|
|
Height = 400,
|
|
Content = new Avalonia.Controls.ScrollViewer
|
|
{
|
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
|
Content = new StackPanel { Children = { _menu, Footer } }
|
|
}
|
|
};
|
|
_window.Show();
|
|
_window.SetRenderScaling(renderScaling);
|
|
Pump();
|
|
Items = _menu.Items.Cast<NavMenuNode>()
|
|
.Select(node => (NavMenuItem)_menu.ContainerFromItem(node)!).ToArray();
|
|
Actors = Items.Select(item => item.GetVisualDescendants().OfType<BaseMotionActor>()
|
|
.First(actor => actor.Name == "PART_ChildItemsLayoutTransform")).ToArray();
|
|
var clockProperty = typeof(Animatable).GetProperty("Clock", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
foreach (var actor in Actors)
|
|
{
|
|
clockProperty.SetValue(actor, _clock.Instance);
|
|
}
|
|
}
|
|
|
|
public void OpenWithoutMotion(int index)
|
|
{
|
|
Items[index].Open();
|
|
Pump();
|
|
}
|
|
|
|
public void EnableMotion()
|
|
{
|
|
_menu.IsMotionEnabled = true;
|
|
foreach (var item in Items)
|
|
{
|
|
item.OpenCloseMotionDuration = TimeSpan.FromSeconds(1);
|
|
}
|
|
}
|
|
|
|
public void DisableMotion() => _menu.IsMotionEnabled = false;
|
|
|
|
public void Detach()
|
|
{
|
|
_detachedContent = _window.Content;
|
|
_window.Content = null;
|
|
Pump();
|
|
}
|
|
|
|
public void Reattach()
|
|
{
|
|
_window.Content = _detachedContent;
|
|
_detachedContent = null;
|
|
Pump();
|
|
}
|
|
|
|
public void ClickHeader(int index)
|
|
{
|
|
var header = Items[index].GetVisualDescendants().OfType<Control>().First(control => control.Name == "PART_Header");
|
|
var point = header.TranslatePoint(new Point(header.Bounds.Width / 2, header.Bounds.Height / 2), _window)!.Value;
|
|
_window.MouseMove(point);
|
|
_window.MouseDown(point, MouseButton.Left);
|
|
_window.MouseUp(point, MouseButton.Left);
|
|
Pump();
|
|
}
|
|
|
|
public void Step(int milliseconds)
|
|
{
|
|
_clock.Step(milliseconds);
|
|
Pump();
|
|
}
|
|
|
|
public void StepBeforeContinuations(int milliseconds)
|
|
{
|
|
_clock.Step(milliseconds);
|
|
_window.UpdateLayout();
|
|
}
|
|
|
|
public void Pump()
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
_window.UpdateLayout();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_window.Close();
|
|
Pump();
|
|
}
|
|
}
|
|
|
|
private sealed class ManualClock
|
|
{
|
|
// Avalonia's animation clock is internal; keep deterministic clock access in tests only.
|
|
private static readonly Type ClockType = typeof(Animation).Assembly.GetType("Avalonia.Animation.ClockBase", true)!;
|
|
private static readonly MethodInfo Pulse = ClockType.GetMethod("Pulse", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
public object Instance { get; } = Activator.CreateInstance(ClockType, nonPublic: true)!;
|
|
public void Step(int milliseconds) => Pulse.Invoke(Instance, [TimeSpan.FromMilliseconds(milliseconds)]);
|
|
}
|
|
}
|