Files

762 lines
24 KiB
C#

using System.Globalization;
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Shouldly;
using Xunit;
using AtomUISlider = AtomUI.Desktop.Controls.Slider;
using AtomUISliderMark = AtomUI.Desktop.Controls.SliderMark;
using AtomUISliderTrack = AtomUI.Desktop.Controls.SliderTrack;
using AtomUIToolTip = AtomUI.Desktop.Controls.ToolTip;
using AvaloniaWindow = Avalonia.Controls.Window;
namespace AtomUI.Desktop.Controls.Tests.Slider;
public class SliderBehaviorTests
{
static SliderBehaviorTests()
{
AvaloniaTestApp.EnsureInitialized();
}
[Fact]
public void Snap_To_Decimal_Tick_Normalizes_Value_Written_By_Step_Move()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 1,
SmallChange = 0.1,
TickFrequency = 0.1,
IsSnapToTickEnabled = true,
Value = 0.2
};
InvokePrivate(slider, "MoveToNextTick", 0.1);
slider.Value.ToString("R", CultureInfo.InvariantCulture).ShouldBe("0.3");
}
[Fact]
public void Mark_Label_Brush_Change_Rebuilds_Cached_Formatted_Text()
{
var mark = new AtomUISliderMark("30", 30);
var track = new AtomUISliderTrack
{
Marks = [mark],
MarkLabelFontSize = 12,
MarkLabelBrush = Brushes.Red
};
InvokePrivate(track, "CalculateMaxMarkSize", false);
var formattedText = GetInternalProperty<object>(mark, "FormattedText");
track.MarkLabelBrush = Brushes.Blue;
GetInternalProperty<object>(mark, "FormattedText")
.ShouldNotBeSameAs(formattedText);
}
[Fact]
public void MarkLabelFontFamily_Setter_Stores_FontFamily()
{
var track = new AtomUISliderTrack();
var fontFamily = new FontFamily("Arial");
track.MarkLabelFontFamily = fontFamily;
track.MarkLabelFontFamily.ShouldBe(fontFamily);
}
[Fact]
public void Detaching_Slider_Releases_Template_Pointer_Handlers()
{
var slider = new AtomUISlider();
var window = new AvaloniaWindow
{
Width = 240,
Height = 160,
Content = slider
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
GetPrivateField<IDisposable?>(slider, "_pointerMovedDispose").ShouldNotBeNull();
GetPrivateField<IDisposable?>(slider, "_pointerPressDispose").ShouldNotBeNull();
GetPrivateField<IDisposable?>(slider, "_pointerReleaseDispose").ShouldNotBeNull();
window.Close();
Dispatcher.UIThread.RunJobs();
GetPrivateField<IDisposable?>(slider, "_pointerMovedDispose").ShouldBeNull();
GetPrivateField<IDisposable?>(slider, "_pointerPressDispose").ShouldBeNull();
GetPrivateField<IDisposable?>(slider, "_pointerReleaseDispose").ShouldBeNull();
}
finally
{
window.Close();
}
}
[Fact]
public void Enabling_Range_Mode_After_Template_Configures_Range_Thumb_ToolTips()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
Orientation = Orientation.Vertical,
RangeValues = [20, 80],
ValueFormatTemplate = "{0:0}%"
};
var window = new AvaloniaWindow
{
Width = 240,
Height = 240,
Content = slider
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
track!.Thumbs.Count.ShouldBe(1);
slider.IsRangeMode = true;
Dispatcher.UIThread.RunJobs();
track.Thumbs.Count.ShouldBe(2);
AtomUIToolTip.GetPlacement(track.Thumbs[0]).ShouldBe(PlacementMode.Right);
AtomUIToolTip.GetPlacement(track.Thumbs[1]).ShouldBe(PlacementMode.Right);
AtomUIToolTip.GetTip(track.Thumbs[0]).ShouldBe("20%");
AtomUIToolTip.GetTip(track.Thumbs[1]).ShouldBe("80%");
AtomUIToolTip.GetTipHostWidth(track.Thumbs[1]).ShouldBeGreaterThan(0);
}
finally
{
window.Close();
}
}
[Fact]
public void Range_Mode_Template_Creates_Thumb_Per_Range_Value()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [10, 40, 80],
ValueFormatTemplate = "{0:0}%"
};
ShowInWindow(slider, () =>
{
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
track!.Thumbs.Count.ShouldBe(3);
AtomUIToolTip.GetTip(track.Thumbs[0]).ShouldBe("10%");
AtomUIToolTip.GetTip(track.Thumbs[1]).ShouldBe("40%");
AtomUIToolTip.GetTip(track.Thumbs[2]).ShouldBe("80%");
});
}
[Fact]
public void Dynamic_Thumbs_Keep_Slider_As_Templated_Parent()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 50, 80]
};
ShowInWindow(slider, () =>
{
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
track!.Thumbs.ShouldAllBe(thumb => ReferenceEquals(thumb.TemplatedParent, slider));
});
}
[Fact]
public void Dynamic_Thumbs_Follow_Runtime_Motion_Changes()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 50, 80],
IsMotionEnabled = false
};
ShowInWindow(slider, () =>
{
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
track!.Thumbs.ShouldAllBe(thumb => !thumb.IsMotionEnabled);
slider.IsMotionEnabled = true;
Dispatcher.UIThread.RunJobs();
track.Thumbs.ShouldAllBe(thumb => thumb.IsMotionEnabled);
});
}
[Fact]
public void Performance_Slider_Suites_Use_Multi_Handle_Contract()
{
var scenarios = ReadRepoFile(
"tools/performances/AtomUI.Performance/Suites/Slider/SliderScenarios.cs");
scenarios.ShouldContain("RangeValues = [20, 80]");
scenarios.ShouldNotContain("new SliderRangeValue");
scenarios.ShouldNotContain("RangeValue =");
var verification = ReadRepoFile(
"tools/performances/AtomUI.Performance/Suites/Slider/SliderStateVerification.cs");
verification.ShouldContain("GetSliderThumbs(slider)");
verification.ShouldNotContain("StartSliderThumb");
verification.ShouldNotContain("EndSliderThumb");
verification.ShouldNotContain("new SliderRangeValue");
verification.ShouldNotContain("RangeValue =");
}
[Fact]
public void Range_Mode_With_Marks_Keeps_Thumbs_Centered_On_Rail()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 80],
Marks =
[
new AtomUISliderMark("0", 0),
new AtomUISliderMark("20", 20),
new AtomUISliderMark("80", 80),
new AtomUISliderMark("100", 100)
]
};
ShowInWindow(slider, () =>
{
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
var railRect = InvokePrivate<Rect>(track!, "GetRailRect", track.Bounds.Size);
foreach (var thumb in track!.Thumbs)
{
thumb.Bounds.Center.Y.ShouldBe(railRect.Center.Y, 0.5);
}
});
}
[Fact]
public void Template_Thumb_Drag_Is_Handled_Only_By_Slider_Pointer_Pipeline()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 80]
};
ShowInWindow(slider, () =>
{
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
track!.Thumbs[0].RaiseEvent(new VectorEventArgs
{
RoutedEvent = AtomUI.Desktop.Controls.SliderThumb.DragDeltaEvent,
Vector = new Vector(40, 0)
});
slider.RangeValues.ShouldBe([20, 80]);
});
}
[Fact]
public void Draggable_Range_Track_Does_Not_Claim_Thumb_Press()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
IsDraggableTrack = true,
RangeValues = [20, 50, 80]
};
ShowInWindow(slider, () =>
{
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
foreach (var thumb in track!.Thumbs)
{
track.CanDragRangeTrackAt(thumb.Bounds.Center).ShouldBeFalse();
}
var firstSegmentCenter = new Point(
(track.Thumbs[0].Bounds.Center.X + track.Thumbs[1].Bounds.Center.X) / 2,
track.Thumbs[0].Bounds.Center.Y);
track.CanDragRangeTrackAt(firstSegmentCenter).ShouldBeTrue();
});
}
[Fact]
public void Range_Thumb_Drag_Preserves_Grab_Offset()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 60]
};
ShowInWindow(slider, () =>
{
var window = TopLevel.GetTopLevel(slider).ShouldBeOfType<AvaloniaWindow>();
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
var thumb = track!.Thumbs[1];
var pressPoint = thumb.TranslatePoint(
new Point(thumb.Bounds.Width - 2, thumb.Bounds.Height / 2),
window).ShouldNotBeNull();
var initialCenterX = thumb.Bounds.Center.X;
window.MouseMove(pressPoint);
window.MouseDown(pressPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
slider.RangeValues.ShouldBe([20, 60]);
window.MouseMove(pressPoint + new Vector(30, 0));
Dispatcher.UIThread.RunJobs();
thumb.Bounds.Center.X.ShouldBe(initialCenterX + 30, 0.5);
window.MouseUp(pressPoint + new Vector(30, 0), MouseButton.Left);
});
}
[Fact]
public void Single_Thumb_Drag_By_Subpixel_Distance_Preserves_Fractional_Position()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
Value = 50
};
ShowInWindow(slider, () =>
{
var window = TopLevel.GetTopLevel(slider).ShouldBeOfType<AvaloniaWindow>();
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
var thumb = track!.Thumbs[0];
var initialTrackCenter = InvokePrivate<Point>(track,
"ValueToCenterPoint",
track.Bounds.Size,
track.Value);
var pressPoint = thumb.TranslatePoint(
new Point(thumb.Bounds.Width / 2, thumb.Bounds.Height / 2),
window).ShouldNotBeNull();
const double dragDistance = 0.25;
window.MouseMove(pressPoint);
window.MouseDown(pressPoint, MouseButton.Left);
window.MouseMove(pressPoint + new Vector(dragDistance, 0));
Dispatcher.UIThread.RunJobs();
track.UpdateLayout();
var railRect = InvokePrivate<Rect>(track, "GetRailRect", track.Bounds.Size);
slider.Value.ShouldBe(50 + 100 * dragDistance / railRect.Width, 0.0001);
track.Value.ShouldBe(slider.Value);
var expectedCenter = InvokePrivate<Point>(track,
"ValueToCenterPoint",
track.Bounds.Size,
track.Value);
expectedCenter.X.ShouldBe(initialTrackCenter.X + dragDistance, 0.0001);
thumb.Bounds.Center.X.ShouldBe(expectedCenter.X, 0.0001);
window.MouseUp(pressPoint + new Vector(dragDistance, 0), MouseButton.Left);
});
}
[Fact]
public void Range_Thumb_Repeated_One_Pixel_Moves_Remain_Continuous()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 60]
};
ShowInWindow(slider, () =>
{
var window = TopLevel.GetTopLevel(slider).ShouldBeOfType<AvaloniaWindow>();
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
var thumb = track!.Thumbs[1];
var pressPoint = thumb.TranslatePoint(
new Point(thumb.Bounds.Width - 2, thumb.Bounds.Height / 2),
window).ShouldNotBeNull();
var initialCenterX = thumb.Bounds.Center.X;
var previousValue = slider.RangeValues![1];
window.MouseMove(pressPoint);
window.MouseDown(pressPoint, MouseButton.Left);
for (var offset = 1; offset <= 20; offset++)
{
window.MouseMove(pressPoint + new Vector(offset, 0));
Dispatcher.UIThread.RunJobs();
var currentValue = slider.RangeValues![1];
currentValue.ShouldBeGreaterThan(previousValue);
(currentValue - previousValue).ShouldBeLessThan(1);
thumb.Bounds.Center.X.ShouldBe(initialCenterX + offset, 0.5);
previousValue = currentValue;
}
previousValue.ShouldNotBe(Math.Round(previousValue));
window.MouseUp(pressPoint + new Vector(20, 0), MouseButton.Left);
});
}
[Fact]
public void Range_Thumb_Drag_Snaps_Only_When_Enabled()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
IsSnapToTickEnabled = true,
TickFrequency = 5,
RangeValues = [20, 60]
};
ShowInWindow(slider, () =>
{
var window = TopLevel.GetTopLevel(slider).ShouldBeOfType<AvaloniaWindow>();
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
var thumb = track!.Thumbs[1];
var pressPoint = thumb.TranslatePoint(
new Point(thumb.Bounds.Width / 2, thumb.Bounds.Height / 2),
window).ShouldNotBeNull();
window.MouseMove(pressPoint);
window.MouseDown(pressPoint, MouseButton.Left);
window.MouseMove(pressPoint + new Vector(12, 0));
Dispatcher.UIThread.RunJobs();
slider.RangeValues.ShouldBe([20, 65]);
window.MouseUp(pressPoint + new Vector(12, 0), MouseButton.Left);
});
}
[Fact]
public void Range_Thumb_Captures_Pointer_Until_Release()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 60]
};
ShowInWindow(slider, () =>
{
var window = TopLevel.GetTopLevel(slider).ShouldBeOfType<AvaloniaWindow>();
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
var thumb = track!.Thumbs[1];
var pressPoint = thumb.TranslatePoint(
new Point(thumb.Bounds.Width / 2, thumb.Bounds.Height / 2),
window).ShouldNotBeNull();
var outsideSlider = new Point(window.Bounds.Width - 2, window.Bounds.Height - 2);
window.MouseMove(pressPoint);
window.MouseDown(pressPoint, MouseButton.Left);
window.MouseMove(outsideSlider);
Dispatcher.UIThread.RunJobs();
slider.RangeValues![1].ShouldBeGreaterThan(60);
window.MouseUp(outsideSlider, MouseButton.Left);
var releasedValues = slider.RangeValues;
window.MouseMove(new Point(2, window.Bounds.Height - 2));
Dispatcher.UIThread.RunJobs();
slider.RangeValues.ShouldBeSameAs(releasedValues);
});
}
[Fact]
public void DisabledHandles_Disable_Corresponding_Template_Thumbs()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [10, 40, 80],
DisabledHandles = [false, true, false]
};
ShowInWindow(slider, () =>
{
var track = GetInternalProperty<AtomUISliderTrack?>(slider, "SliderTrack");
track.ShouldNotBeNull();
track!.Thumbs[0].IsEnabled.ShouldBeTrue();
track.Thumbs[1].IsEnabled.ShouldBeFalse();
track.Thumbs[2].IsEnabled.ShouldBeTrue();
});
}
[Fact]
public void MoveRangeHandle_Ignores_Disabled_Handle()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [10, 40, 80],
DisabledHandles = [false, true, false]
};
InvokePrivate(slider, "MoveRangeHandle", 1, 60d);
slider.RangeValues.ShouldBe([10, 40, 80]);
}
[Fact]
public void MoveRangeHandle_Stops_At_Disabled_Neighbor()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 50, 80],
DisabledHandles = [false, true, false]
};
InvokePrivate(slider, "MoveRangeHandle", 0, 75d);
slider.RangeValues.ShouldBe([50, 50, 80]);
slider.RangeValues = [20, 50, 80];
InvokePrivate(slider, "MoveRangeHandle", 2, 25d);
slider.RangeValues.ShouldBe([20, 50, 50]);
}
[Fact]
public void MoveRangeTrack_Shifts_All_Handles_When_Draggable()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
IsDraggableTrack = true,
RangeValues = [20, 40, 80]
};
InvokePrivate(slider, "MoveRangeTrack", 10d);
slider.RangeValues.ShouldBe([30, 50, 90]);
}
[Fact]
public void MoveRangeTrack_Ignores_Disabled_Handles()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
IsDraggableTrack = true,
RangeValues = [20, 40, 80],
DisabledHandles = [false, true, false]
};
InvokePrivate(slider, "MoveRangeTrack", 10d);
slider.RangeValues.ShouldBe([20, 40, 80]);
}
[Fact]
public void RangeValues_Property_Normalizes_Snapshot_Values()
{
var source = new List<double> { 80, 20, -10, 120, 20 };
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = source
};
slider.RangeValues.ShouldBe([0, 20, 20, 80, 100]);
slider.RangeValues.ShouldNotBeSameAs(source);
source[0] = 5;
slider.RangeValues.ShouldBe([0, 20, 20, 80, 100]);
}
[Fact]
public void RangeValues_Property_Rejects_Non_Finite_Values()
{
var slider = new AtomUISlider
{
Minimum = 0,
Maximum = 100,
IsRangeMode = true,
RangeValues = [20, 80]
};
slider.RangeValues = [double.NaN, 40];
slider.RangeValues.ShouldBe([20, 80]);
slider.RangeValues = [double.NegativeInfinity, 40];
slider.RangeValues.ShouldBe([20, 80]);
}
[Fact]
public void RangeValues_Property_Uses_Minimum_Pair_When_Too_Few_Values_Are_Provided()
{
var slider = new AtomUISlider
{
Minimum = 10,
Maximum = 100,
IsRangeMode = true,
RangeValues = [70]
};
slider.RangeValues.ShouldBe([10, 10]);
}
[Fact]
public void SliderRangeMath_NormalizeRangeValues_Preserves_Valid_Snapshots()
{
IReadOnlyList<double> values = [10, 40, 80];
var normalized = SliderRangeMath.NormalizeRangeValues(values, 0, 100);
normalized.ShouldBeSameAs(values);
}
[Fact]
public void SliderRangeMath_NormalizeRangeValues_Stabilizes_Default_Range_Pair()
{
var normalized = SliderRangeMath.NormalizeRangeValues(null, 0, 100);
SliderRangeMath.NormalizeRangeValues(normalized, 0, 100).ShouldBeSameAs(normalized);
}
[Fact]
public void SliderRangeMath_Finds_Nearest_Enabled_Handle()
{
var values = new[] { 10d, 40d, 80d };
SliderRangeMath.FindNearestEnabledHandleIndex(values, [false, true, false], 38)
.ShouldBe(0);
SliderRangeMath.FindNearestEnabledHandleIndex(values, [false, true, false], 70)
.ShouldBe(2);
}
[Fact]
public void SliderRangeMath_Applies_Track_Offset_Only_When_All_Handles_Can_Move()
{
SliderRangeMath.ApplyTrackOffset([20, 40, 80], [], 30, 0, 100)
.ShouldBe([40, 60, 100]);
SliderRangeMath.ApplyTrackOffset([20, 40, 80], [false, true, false], 10, 0, 100)
.ShouldBe([20, 40, 80]);
}
private static void ShowInWindow(Control content, Action assertion)
{
var window = new AvaloniaWindow
{
Width = 360,
Height = 240,
Content = content
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
assertion();
}
finally
{
window.Close();
}
}
private static void InvokePrivate(object target, string methodName, params object[] args)
{
var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
method.ShouldNotBeNull();
method.Invoke(target, args);
}
private static T InvokePrivate<T>(object target, string methodName, params object[] args)
{
var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
method.ShouldNotBeNull();
return (T)method.Invoke(target, args)!;
}
private static T GetInternalProperty<T>(object target, string propertyName)
{
var property = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.NonPublic);
property.ShouldNotBeNull();
return (T)property.GetValue(target)!;
}
private static T GetPrivateField<T>(object target, string fieldName)
{
var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
field.ShouldNotBeNull();
return (T)field.GetValue(target)!;
}
private static string ReadRepoFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file: {relativePath}");
}
}