Files

227 lines
9.9 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;
using AtomExpander = AtomUI.Desktop.Controls.Expander;
using AtomButton = AtomUI.Desktop.Controls.Button;
using AvaloniaScrollViewer = Avalonia.Controls.ScrollViewer;
using AvaloniaWindow = Avalonia.Controls.Window;
namespace AtomUI.Desktop.Controls.Tests.Motion;
public class ContentExpansionInputTests
{
static ContentExpansionInputTests() => AvaloniaTestApp.EnsureInitialized();
[Theory]
[InlineData(1)]
[InlineData(2)]
public void Nested_Wheel_Scrolling_And_Boundary_Chaining_Remain_Available_During_Collapse(double scaling)
{
using var scene = new InputScene(scaling);
scene.AssertOverflowAndScrollbars();
var viewport = scene.Inner.Viewport;
var extent = scene.Inner.Extent;
scene.StartClosing();
// Shrinking the expansion viewport must not resize the fixed-height inner scroller.
scene.Inner.Viewport.ShouldBe(viewport);
scene.Inner.Extent.ShouldBe(extent);
scene.AssertOverflowAndScrollbars();
var point = scene.Inner.TranslatePoint(new Point(20, 16), scene.Window)!.Value;
scene.Actor.TranslatePoint(default, scene.Window)!.Value.Y.ShouldBeLessThan(point.Y);
point.Y.ShouldBeLessThan(scene.Actor.TranslatePoint(new Point(0, scene.Actor.Bounds.Height), scene.Window)!.Value.Y);
scene.Wheel(point);
scene.Inner.Offset.Y.ShouldBeGreaterThan(0, "Wheel input inside the still-visible content must scroll the inner viewport.");
scene.Outer.Offset.Y.ShouldBe(0, "The outer scroller must not consume a wheel event handled by the inner scroller.");
// Only boundary setup uses Offset; the transition to the outer scroller is real wheel input.
scene.Inner.Offset = new Vector(0, scene.Inner.Extent.Height - scene.Inner.Viewport.Height);
scene.Pump();
var innerBoundary = scene.Inner.Offset.Y;
scene.Wheel(point);
scene.Inner.Offset.Y.ShouldBe(innerBoundary);
scene.Outer.Offset.Y.ShouldBeGreaterThan(0,
"At the inner boundary, wheel input must chain to the overflowing outer ScrollViewer.");
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public void Animation_Clipping_Rejects_Hidden_Buttons_And_Preserves_Visible_Button_Input(double scaling)
{
using var scene = new InputScene(scaling);
scene.AssertOverflowAndScrollbars();
var visibleClicks = 0;
var clippedClicks = 0;
scene.VisibleButton.Click += (_, _) => visibleClicks++;
scene.ClippedButton.Click += (_, _) => clippedClicks++;
// Prove both buttons are reachable under the original fixed-size and scrolling constraints.
scene.ClickCenter(scene.VisibleButton);
scene.ClickCenter(scene.ClippedButton);
visibleClicks.ShouldBe(1);
clippedClicks.ShouldBe(1);
visibleClicks = clippedClicks = 0;
scene.StartClosing();
var visiblePoint = scene.Center(scene.VisibleButton);
var clippedPoint = scene.Center(scene.ClippedButton);
var viewportBottom = scene.Actor.TranslatePoint(new Point(0, scene.Actor.Bounds.Height), scene.Window)!.Value.Y;
visiblePoint.Y.ShouldBeLessThan(viewportBottom);
clippedPoint.Y.ShouldBeGreaterThan(viewportBottom,
"The second button must be outside the animated viewport, rather than merely transparent.");
clippedPoint.Y.ShouldBeLessThan(scene.Window.ClientSize.Height,
"The negative hit-test point must remain inside the actual window.");
scene.ClippedButton.IsEffectivelyVisible.ShouldBeTrue(
"This regression must exercise ancestor clipping rather than hiding or disabling the button.");
scene.ClippedButton.IsEffectivelyEnabled.ShouldBeTrue();
scene.Click(clippedPoint);
clippedClicks.ShouldBe(0, "A real pointer click beyond the expansion viewport must not reach clipped content.");
scene.Click(visiblePoint);
visibleClicks.ShouldBe(1, "The same animation frame must preserve input in the visible region.");
}
private sealed class InputScene : IDisposable
{
private readonly ManualClock _clock = new();
private readonly AtomExpander _expander;
public AvaloniaWindow Window { get; }
public AvaloniaScrollViewer Inner { get; }
public AvaloniaScrollViewer Outer { get; }
public BaseMotionActor Actor { get; }
public AtomButton VisibleButton { get; } = new() { Content = "Visible action", Height = 32 };
public AtomButton ClippedButton { get; } = new() { Content = "Clipped action", Height = 32 };
public InputScene(double scaling)
{
var rows = new StackPanel
{
Children =
{
VisibleButton,
new Border { Height = 112 },
ClippedButton
}
};
for (var i = 0; i < 30; i++)
rows.Children.Add(new TextBlock { Text = $"Scrollable row {i}", Height = 32 });
Inner = new AvaloniaScrollViewer
{
Height = 220,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
IsScrollChainingEnabled = true,
Content = rows
};
_expander = new AtomExpander
{
Header = "Nested scrolling",
Content = Inner,
ContentPadding = default(Thickness),
IsExpanded = true,
IsMotionEnabled = false,
MotionDuration = TimeSpan.FromSeconds(1)
};
Outer = new AvaloniaScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
Content = new StackPanel
{
Children =
{
new Avalonia.Controls.Grid { Height = 300, Children = { _expander } },
new Border { Height = 900 }
}
}
};
Window = new AvaloniaWindow { Width = 420, Height = 360, Content = Outer };
Window.Show();
Window.SetRenderScaling(scaling);
Pump();
Actor = _expander.GetVisualDescendants().OfType<BaseMotionActor>()
.First(control => control.Name == "PART_ContentMotionActor");
typeof(Animatable).GetProperty("Clock", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(Actor, _clock.Instance);
}
public void StartClosing()
{
var fullHeight = Actor.Bounds.Height;
_expander.IsMotionEnabled = true;
var header = _expander.GetVisualDescendants().OfType<Control>()
.First(control => control.Name == "PART_HeaderDecorator");
ClickCenter(header);
_expander.IsExpanded.ShouldBeFalse();
_clock.Step(0);
Pump();
_clock.Step(500);
Pump();
Actor.IsVisible.ShouldBeTrue();
Actor.Bounds.Height.ShouldBeGreaterThan(0);
Actor.Bounds.Height.ShouldBeLessThan(fullHeight);
Actor.Opacity.ShouldBeGreaterThan(0);
Actor.Opacity.ShouldBeLessThan(1);
}
public void AssertOverflowAndScrollbars()
{
Inner.Bounds.Height.ShouldBe(220);
Inner.Viewport.Height.ShouldBeGreaterThan(0);
Inner.Extent.Height.ShouldBeGreaterThan(Inner.Viewport.Height + 500);
Outer.Viewport.Height.ShouldBeGreaterThan(0);
Outer.Extent.Height.ShouldBeGreaterThan(Outer.Viewport.Height + 500);
foreach (var viewer in new[] { Inner, Outer })
{
var scrollbar = viewer.GetVisualDescendants().OfType<Avalonia.Controls.Primitives.ScrollBar>()
.First(bar => bar.Orientation == Orientation.Vertical && ReferenceEquals(bar.TemplatedParent, viewer));
scrollbar.IsEffectivelyVisible.ShouldBeTrue();
scrollbar.Bounds.Width.ShouldBeGreaterThan(0);
scrollbar.Bounds.Height.ShouldBeGreaterThan(0);
scrollbar.Maximum.ShouldBeGreaterThan(0);
}
}
public Point Center(Control control) => control.TranslatePoint(
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), Window)!.Value;
public void ClickCenter(Control control) => Click(Center(control));
public void Click(Point point)
{
Window.MouseMove(point);
Window.MouseDown(point, MouseButton.Left);
Window.MouseUp(point, MouseButton.Left);
Pump();
}
public void Wheel(Point point)
{
Window.MouseMove(point);
Window.MouseWheel(point, new Vector(0, -1), RawInputModifiers.None);
Pump();
}
public void Pump() { Dispatcher.UIThread.RunJobs(); Window.UpdateLayout(); }
public void Dispose() { Window.Close(); Pump(); }
}
private sealed class ManualClock
{
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)]);
}
}