using AtomUI.Controls; using AtomUI.Controls.Commons; using AtomUI.Controls.Primitives; using AtomUI.Utils; using Avalonia; using Avalonia.Controls; using Avalonia.Threading; using Avalonia.VisualTree; using Shouldly; using Xunit; namespace AtomUI.Desktop.Controls.Tests.FloatButton; public class BackTopFloatButtonProgressTests { static BackTopFloatButtonProgressTests() { AvaloniaTestApp.EnsureInitialized(); } private static AtomUI.Desktop.Controls.ScrollViewer CreateScrollContent( BackTopFloatButton backTop, double contentHeight, out Avalonia.Controls.Window window) { var panel = new Panel { Height = contentHeight }; panel.Children.Add(new Avalonia.Controls.TextBlock { Text = "content" }); panel.Children.Add(backTop); var scrollViewer = new AtomUI.Desktop.Controls.ScrollViewer { Width = 320, Height = 240, Content = panel }; backTop.Target = scrollViewer; window = new Avalonia.Controls.Window { Width = 320, Height = 240, Content = scrollViewer }; window.Show(); Dispatcher.UIThread.RunJobs(); return scrollViewer; } [Fact] public void BackTop_IsShowProgress_Should_Default_To_False() { var backTop = new BackTopFloatButton(); backTop.IsShowProgress.ShouldBeFalse(); } [Fact] public void BackTop_ScrollProgress_Should_Be_Zero_When_Content_Shorter_Than_Viewport() { var backTop = new BackTopFloatButton { VisibilityHeight = 0 }; var scrollViewer = CreateScrollContent(backTop, 100, out var window); try { scrollViewer.Offset = new Vector(0, 50); Dispatcher.UIThread.RunJobs(); backTop.ScrollProgress.ShouldBe(0d); } finally { window.Close(); } } [Theory] [InlineData(0d)] // 名义 0 / 760 = 0 [InlineData(190d)] // 名义 190 / 760 = 0.25 [InlineData(380d)] // 名义 380 / 760 = 0.5 [InlineData(760d)] // 名义 760 / 760 = 1 [InlineData(9999d)] // 超出范围夹取到 1 public void BackTop_ScrollProgress_Should_Follow_Scroll_Offset(double offset) { var backTop = new BackTopFloatButton { VisibilityHeight = 0 }; // 名义 viewport 240 + content 1000 => maxScroll = 760;实际 maxScroll 运行时读取, // 避免布局亚像素差异导致的期望值误报 var scrollViewer = CreateScrollContent(backTop, 1000, out var window); try { scrollViewer.Offset = new Vector(0, offset); Dispatcher.UIThread.RunJobs(); var maxScroll = Math.Max( scrollViewer.Extent.Height - scrollViewer.Viewport.Height, 0d); var expected = maxScroll > 0d ? Math.Clamp(offset / maxScroll, 0d, 1d) : 0d; backTop.ScrollProgress.ShouldBe(expected, 0.001d); } finally { window.Close(); } } [Fact] public void BackTop_ScrollProgress_Should_Sync_On_Loaded() { var backTop = new BackTopFloatButton { VisibilityHeight = 0 }; var panel = new Panel { Height = 1000 }; panel.Children.Add(backTop); var scrollViewer = new AtomUI.Desktop.Controls.ScrollViewer { Width = 320, Height = 240, Content = panel }; backTop.Target = scrollViewer; var window = new Avalonia.Controls.Window { Width = 320, Height = 240, Content = scrollViewer }; try { // 挂载前预设 Offset=190(名义 maxScroll=760 → 25%),由 OnLoaded 同步 scrollViewer.Offset = new Vector(0, 190); window.Show(); Dispatcher.UIThread.RunJobs(); var maxScroll = Math.Max( scrollViewer.Extent.Height - scrollViewer.Viewport.Height, 0d); backTop.ScrollProgress.ShouldBe(190d / maxScroll, 0.001d); } finally { window.Close(); } } private static BackTopFloatButton CreateShownButton(FloatButtonShape shape, out Avalonia.Controls.Window window) { var backTop = new BackTopFloatButton { Shape = shape, IsShowProgress = true, VisibilityHeight = 0, IsMotionEnabled = false }; var panel = new Panel { Height = 1000 }; panel.Children.Add(backTop); var scrollViewer = new AtomUI.Desktop.Controls.ScrollViewer { Width = 320, Height = 240, Content = panel }; backTop.Target = scrollViewer; window = new Avalonia.Controls.Window { Width = 320, Height = 240, Content = scrollViewer }; window.Show(); Dispatcher.UIThread.RunJobs(); return backTop; } [Fact] public void BackTop_ProgressRing_Hidden_By_Default() { // IsShowProgress 保持默认 false(Task 1 已断言默认值),环部件应不可见 var backTop = new BackTopFloatButton { VisibilityHeight = 0, IsMotionEnabled = false }; CreateScrollContent(backTop, 1000, out var window); try { var ring = backTop.GetVisualDescendants().OfType().Single(); ring.IsVisible.ShouldBeFalse(); } finally { window.Close(); } } [Theory] [InlineData(FloatButtonShape.Circle)] [InlineData(FloatButtonShape.Square)] public void BackTop_ProgressRing_Shown_When_IsShowProgress(FloatButtonShape shape) { var backTop = CreateShownButton(shape, out var window); try { var ring = backTop.GetVisualDescendants().OfType().Single(); ring.IsVisible.ShouldBeTrue(); ring.IndicatorBrush.ShouldNotBeNull(); ring.TrackBrush.ShouldNotBeNull(); ring.StrokeThickness.ShouldBe(2d); // LineWidthBold backTop.Target!.Offset = new Vector(0, 380); Dispatcher.UIThread.RunJobs(); // 名义 viewport 240 + content 1000 => maxScroll = 760;实际 maxScroll 运行时读取, // 避免布局亚像素差异导致的期望值误报 var scrollViewer = backTop.Target; var maxScroll = scrollViewer.Extent.Height - scrollViewer.Viewport.Height; ring.Progress.ShouldBe(380d / maxScroll, 0.001d); } finally { window.Close(); } } [Fact] public void BackTop_ProgressRing_CornerRadius_Should_Follow_Shape() { var circle = CreateShownButton(FloatButtonShape.Circle, out var window1); var square = CreateShownButton(FloatButtonShape.Square, out var window2); try { var circleRing = circle.GetVisualDescendants().OfType().Single(); var squareRing = square.GetVisualDescendants().OfType().Single(); circleRing.CornerRadius.TopLeft.ShouldBe(circle.Bounds.Height / 2, 0.01d); squareRing.CornerRadius.TopLeft.ShouldBeGreaterThan(0d); squareRing.CornerRadius.TopLeft.ShouldBeLessThan(square.Bounds.Height / 2); } finally { window1.Close(); window2.Close(); } } [Fact] public void BackTopHost_IsShowProgress_Should_Project_To_Overlay_Button() { var host = new BackTopFloatButtonHost { IsShowProgress = true, IsMotionEnabled = false, VisibilityHeight = 0 }; var panel = new ScopeAwareOverlayLayerPanel { Width = 320, Height = 240 }; panel.Children.Add(host); var window = new Avalonia.Controls.Window { Width = 320, Height = 240, Content = panel }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var overlayLayer = AtomUI.Controls.Primitives.ScopeAwareOverlayLayer.FindLayer(panel); overlayLayer.ShouldNotBeNull(); var overlayButton = overlayLayer.GetVisualDescendants() .OfType() .Single(); overlayButton.IsShowProgress.ShouldBeTrue(); } finally { window.Close(); } } [Fact] public void BackTopHost_Two_Hosts_Should_Not_Overlap_When_Offset_Configured() { // Gallery 的 BackTop 进度环示例并排展示圆形与方形按钮:Host 不参与布局 // (desired=0,0),投影位置由 Placement + FloatOffset 决定,因此两个 Host // 若都用默认偏移会完全重合(阴影叠加、前者被遮挡)。此测试锁定该示例的 // 偏移契约:Square 需与 Circle 分离且位于其左侧(对齐 antd demo 排列)。 var circleHost = new BackTopFloatButtonHost { ButtonType = FloatButtonType.Default, IsShowProgress = true, VisibilityHeight = 0 }; var squareHost = new BackTopFloatButtonHost { ButtonType = FloatButtonType.Default, Shape = FloatButtonShape.Square, IsShowProgress = true, VisibilityHeight = 0, FloatOffsetX = 72 }; var panel = new Panel { Height = 1000 }; panel.Children.Add(circleHost); panel.Children.Add(squareHost); var scrollViewer = new AtomUI.Desktop.Controls.ScrollViewer { Width = 600, Height = 300, Content = new Border { Child = panel } }; var window = new Avalonia.Controls.Window { Width = 600, Height = 300, Content = scrollViewer }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var buttons = window.GetVisualDescendants() .OfType() .ToList(); buttons.Count.ShouldBe(2); var circle = buttons.Single(b => b.Shape == FloatButtonShape.Circle); var square = buttons.Single(b => b.Shape == FloatButtonShape.Square); var circleLeft = Canvas.GetLeft(circle); var squareLeft = Canvas.GetLeft(square); var circleTop = Canvas.GetTop(circle); var squareTop = Canvas.GetTop(square); squareTop.ShouldBe(circleTop, 0.01d); // 同高 squareLeft.ShouldBeLessThan(circleLeft); // 方形在左 (circleLeft - squareLeft).ShouldBeGreaterThanOrEqualTo(circle.Bounds.Width); // 不重合 } finally { window.Close(); } } [Theory] [InlineData(40d, 8d)] // Square:CornerRadius = BorderRadiusLG [InlineData(40d, 20d)] // Circle:CornerRadius = Height / 2 public void BackTop_Ring_OuterEdge_Should_Match_Button_CornerRadius(double size, double cornerRadius) { // 环的外缘必须与按钮轮廓完全重合(半径 = 按钮圆角半径), // 否则圆角处会露出背景与环之间的缝隙。 var metrics = BackTopProgressRing.CalculateRingMetrics(new Size(size, size), cornerRadius, 2d); metrics.OuterRadius.ShouldBe(cornerRadius, 0.0001d); metrics.CenterlineRadius.ShouldBe(cornerRadius - 1d, 0.0001d); metrics.InnerRadius.ShouldBe(cornerRadius - 2d, 0.0001d); metrics.OuterRect.ShouldBe(new Rect(new Size(size, size))); } [Theory] [InlineData(40d, 8d)] // Square [InlineData(40d, 20d)] // Circle public void BackTop_Ring_Band_Should_Cover_Button_Outline(double size, double cornerRadius) { // 沿按钮轮廓的四个圆角采样:这些点必须落在环带内(在外边界内、不在内孔里)。 // 这是圆角缝隙的直接几何判据——缝隙正是「按钮轮廓上未被环带覆盖」的区域。 // 使用自建圆角矩形包含判据而非 geometry.FillContains:后者在 headless 环境 // 下对 StreamGeometry 的命中测试不可靠(几何中心点都会被判为外部)。 const double thickness = 2d; var metrics = BackTopProgressRing.CalculateRingMetrics(new Size(size, size), cornerRadius, thickness); // 稍向内采样 0.05px,避免恰好落在边界上的浮点歧义。 // 每个圆角只采样其「向外」的 90° 象限:这些点才真正落在按钮轮廓上。 var sampleRadius = cornerRadius - 0.05d; var cornerSamples = new[] { (Center: new Point(cornerRadius, cornerRadius), StartAngle: 180d), // 左上 (Center: new Point(size - cornerRadius, cornerRadius), StartAngle: 270d), // 右上 (Center: new Point(size - cornerRadius, size - cornerRadius), StartAngle: 0d), // 右下 (Center: new Point(cornerRadius, size - cornerRadius), StartAngle: 90d) // 左下 }; foreach (var (cornerCenter, startAngle) in cornerSamples) { for (var offset = 0d; offset <= 90d; offset += 10d) { var rad = MathUtils.Deg2Rad(startAngle + offset); var point = new Point( cornerCenter.X + sampleRadius * Math.Cos(rad), cornerCenter.Y + sampleRadius * Math.Sin(rad)); InsideRoundedRect(metrics.OuterRect, metrics.OuterRadius, point) .ShouldBeTrue($"outline point {point} must be covered by ring outer boundary"); InsideRoundedRect(metrics.InnerRect, metrics.InnerRadius, point) .ShouldBeFalse($"outline point {point} must not fall inside ring inner hole"); } } } private static bool InsideRoundedRect(Rect rect, double radius, Point point) { const double epsilon = 1e-9; if (point.X < rect.Left - epsilon || point.X > rect.Right + epsilon || point.Y < rect.Top - epsilon || point.Y > rect.Bottom + epsilon) { return false; } radius = Math.Clamp(radius, 0d, Math.Min(rect.Width, rect.Height) / 2); var centerX = Math.Clamp(point.X, rect.Left + radius, rect.Right - radius); var centerY = Math.Clamp(point.Y, rect.Top + radius, rect.Bottom - radius); var inCornerX = point.X < rect.Left + radius || point.X > rect.Right - radius; var inCornerY = point.Y < rect.Top + radius || point.Y > rect.Bottom - radius; if (inCornerX && inCornerY) { var dx = point.X - centerX; var dy = point.Y - centerY; return dx * dx + dy * dy <= radius * radius + epsilon; } return true; } }