diff --git a/.github/skills/mtp-test-running/SKILL.md b/.github/skills/mtp-test-running/SKILL.md new file mode 100644 index 0000000000..55f3a38687 --- /dev/null +++ b/.github/skills/mtp-test-running/SKILL.md @@ -0,0 +1,72 @@ +--- +name: mtp-test-running +description: Run MaterialDesignInXamlToolkit tests correctly with dotnet test, TUnit, and Microsoft Testing Platform. Use when validating changes, choosing a safe test scope, or filtering to the exact affected tests. +--- + +# Test running with MTP + +Use this skill when you need to run or suggest test commands in this repository. + +## Repository-specific runner setup + +1. Test projects use `UseMicrosoftTestingPlatformRunner=true`. +2. Test projects also set `TestingPlatformDotnetTestSupport=true`. +3. `global.json` does **not** opt into `test.runner = Microsoft.Testing.Platform`. +4. That means this repo currently uses `dotnet test` in the legacy CLI mode while delegating execution to MTP-backed test applications. +5. Runner-specific arguments must therefore be passed after an extra `--`. +6. Do **not** rely on top-level `dotnet test --filter ...` for these projects. + +## Strong preferences + +1. Start with the smallest non-UI test scope that covers the change. +2. Avoid `MaterialDesignThemes.UITests` unless the change is inherently runtime WPF behavior. +3. Only run UI tests after the user explicitly asks or grants permission. +4. When UI tests are necessary, run only the affected tests, never the whole UI suite. + +## TUnit filter syntax + +TUnit uses `--treenode-filter`. + +Format: + +`////` + +- Use `*` as a wildcard within a segment. +- Use `(A)|(B)` inside a segment for OR. +- Prefer exact class and test names over namespace-wide filters. +- Add `--minimum-expected-tests ` so an empty selection fails loudly. + +## Command patterns + +### Targeted non-UI test run + +```powershell +dotnet test tests\MaterialDesignThemes.Wpf.Tests\MaterialDesignThemes.Wpf.Tests.csproj -c Release --no-build -- --treenode-filter "/*/*/UpDownButtonsPaddingConverterTests/*" --minimum-expected-tests 1 --no-ansi --no-progress +``` + +### Exact DecimalUpDown UI validation + +```powershell +dotnet test tests\MaterialDesignThemes.UITests\MaterialDesignThemes.UITests.csproj -c Release --no-build -- --treenode-filter "/*/*/DecimalUpDownTests/(UpDownButtonsVisibility_Collapsed_RemovesReservedPadding)|(UpDownButtonsVisibility_Hidden_PreservesReservedPadding)" --minimum-expected-tests 2 --no-ansi --no-progress +``` + +### Verify a UI filter before running + +```powershell +tests\MaterialDesignThemes.UITests\bin\Release\net10.0-windows\MaterialDesignThemes.UITests.exe --list-tests --no-ansi --treenode-filter "/*/*/DecimalUpDownTests/(UpDownButtonsVisibility_Collapsed_RemovesReservedPadding)|(UpDownButtonsVisibility_Hidden_PreservesReservedPadding)" +``` + +## Mistakes to avoid + +- Using top-level `dotnet test --filter ...` for these MTP-backed projects. +- Passing runner-specific flags before the extra `--`. +- Running broad UI suites when one or two targeted tests are enough. +- Forgetting `--minimum-expected-tests`, which can hide a bad filter. + +## Useful references + +- `global.json` +- `tests/MaterialDesignThemes.Wpf.Tests/MaterialDesignThemes.Wpf.Tests.csproj` +- `tests/MaterialDesignThemes.UITests/MaterialDesignThemes.UITests.csproj` +- https://learn.microsoft.com/dotnet/core/testing/unit-testing-with-dotnet-test +- https://tunit.dev/docs/execution/test-filters diff --git a/.github/skills/xamltest-ui-tests/SKILL.md b/.github/skills/xamltest-ui-tests/SKILL.md new file mode 100644 index 0000000000..6165874580 --- /dev/null +++ b/.github/skills/xamltest-ui-tests/SKILL.md @@ -0,0 +1,84 @@ +--- +name: xamltest-ui-tests +description: Write and update WPF UI tests in MaterialDesignThemes.UITests using XAMLTest. Use when template, layout, focus, interaction, or visual behavior needs coverage, or when a reviewer asks to move runtime UI checks out of MaterialDesignThemes.Wpf.Tests. +--- + +# XAMLTest UI tests + +Use this skill when working on runtime WPF behavior in this repository. + +## When to use it + +- A change touches templates, visual states, focus behavior, layout, coordinates, or input handling. +- You need to inspect named template parts such as `PART_TextBox` or `PART_IncreaseButton`. +- A reviewer asks to use XAMLTest instead of regular unit tests. +- You need to assert WPF-only properties like `Padding`, `Visibility`, `ActualWidth`, or `DataContext` state after layout. + +## Repository-specific rules + +1. Put runtime UI behavior tests in `tests/MaterialDesignThemes.UITests`. +2. Do **not** add visual-tree or layout interaction tests to `tests/MaterialDesignThemes.Wpf.Tests`. +3. Prefer extending the existing feature file under `tests/MaterialDesignThemes.UITests/WPF//` instead of creating a generic catch-all test file. +4. Match the existing file's result-recording pattern; many current UI tests use `TestRecorder`, but it should not be treated as a blanket requirement. +5. Ask permission before running UI tests, and only run the affected ones. + +## Standard pattern + +1. Inherit from `TestBase`. +2. Use `LoadXaml()` for focused control scenarios. +3. Use `LoadUserControl()` when you need a sample view model, bindings, or additional focus targets. +4. Get named parts with `GetElement("PART_Name")`. +5. Use `RemoteExecute` with static methods for properties that helpers do not expose directly. +6. Use `Wait.For(...)` for layout-sensitive assertions. + +## What to prefer + +- `GetCoordinates()` for alignment and spacing assertions. +- `RemoteExecute` for `Padding`, `Visibility`, `ActualWidth`, `Margin`, or `DataContext` inspection. +- Keyboard and mouse helpers such as `MoveKeyboardFocus`, `SendKeyboardInput`, and `LeftClick` for interaction. + +## What to avoid + +- Inspecting the visual tree from `MaterialDesignThemes.Wpf.Tests` for runtime template behavior. +- Overly broad UI suites when a single targeted XAMLTest scenario will do. +- Repeating helper code inline when a small static local or private helper method is enough. + +## Example + +```csharp +[Test] +public async Task DecimalUpDown_WhenButtonsAreCollapsed_RemovesReservedPadding() +{ + await using var recorder = new TestRecorder(App); + + Thickness originalPadding = new(4, 5, 6, 7); + var decimalUpDown = await LoadXaml(""" + + """); + var buttonsHost = await decimalUpDown.GetElement("ButtonsHost"); + var textBox = await decimalUpDown.GetElement("PART_TextBox"); + + await Wait.For(async () => + { + await Assert.That(await buttonsHost.RemoteExecute(GetButtonsHostVisibility)).IsEqualTo(Visibility.Collapsed); + await Assert.That(await buttonsHost.RemoteExecute(GetButtonsHostActualWidth)).IsEqualTo(0d); + await Assert.That(await textBox.RemoteExecute(GetTextBoxPadding)).IsEqualTo(originalPadding); + }); + + recorder.Success(); +} + +private static Visibility GetButtonsHostVisibility(StackPanel stackPanel) => stackPanel.Visibility; + +private static double GetButtonsHostActualWidth(StackPanel stackPanel) => stackPanel.ActualWidth; + +private static Thickness GetTextBoxPadding(TextBox textBox) => textBox.Padding; +``` + +## Useful local references + +- `tests/MaterialDesignThemes.UITests/TestBase.cs` +- `tests/MaterialDesignThemes.UITests/XamlTestExtensions.cs` +- Existing feature tests under `tests/MaterialDesignThemes.UITests/WPF/` diff --git a/src/MainDemo.Wpf/NumericUpDown.xaml b/src/MainDemo.Wpf/NumericUpDown.xaml index ec43eda214..223cee3278 100644 --- a/src/MainDemo.Wpf/NumericUpDown.xaml +++ b/src/MainDemo.Wpf/NumericUpDown.xaml @@ -54,6 +54,15 @@ + + + + diff --git a/src/MaterialDesignThemes.Wpf/Converters/Internal/UpDownButtonsPaddingConverter.cs b/src/MaterialDesignThemes.Wpf/Converters/Internal/UpDownButtonsPaddingConverter.cs new file mode 100644 index 0000000000..f1f9745d92 --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/Converters/Internal/UpDownButtonsPaddingConverter.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace MaterialDesignThemes.Wpf.Converters.Internal; + +public sealed class UpDownButtonsPaddingConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values is not [Thickness padding, double buttonsWidth, ..]) + { + return DependencyProperty.UnsetValue; + } + + if (double.IsNaN(buttonsWidth) || double.IsInfinity(buttonsWidth)) + { + return padding; + } + + return new Thickness(padding.Left, padding.Top, padding.Right + buttonsWidth, padding.Bottom); + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.NumericUpDown.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.NumericUpDown.xaml index 391a544bdb..4fe69853d0 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.NumericUpDown.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.NumericUpDown.xaml @@ -47,9 +47,7 @@ - + @@ -60,7 +58,6 @@ - - + Style="{DynamicResource NestedTextBoxStyle}"> + + + + + + + + + + Margin="{TemplateBinding Padding, Converter={StaticResource PartButtonMarginConverter}}" + Content="{TemplateBinding DecreaseContent}" + Foreground="{Binding ElementName=PART_TextBox, Path=Foreground}" + Opacity="{TemplateBinding wpf:HintAssist.HintOpacity}" + Style="{DynamicResource NestedNumericUpDownButtonsStyle}" /> + Margin="{TemplateBinding Padding, Converter={StaticResource PartButtonMarginConverter}}" + Content="{TemplateBinding IncreaseContent}" + Foreground="{Binding ElementName=PART_TextBox, Path=Foreground}" + Opacity="{TemplateBinding wpf:HintAssist.HintOpacity}" + Style="{DynamicResource NestedNumericUpDownButtonsStyle}" /> diff --git a/src/MaterialDesignThemes.Wpf/UpDownBase.cs b/src/MaterialDesignThemes.Wpf/UpDownBase.cs index 0617eb7824..6e1f7491d3 100644 --- a/src/MaterialDesignThemes.Wpf/UpDownBase.cs +++ b/src/MaterialDesignThemes.Wpf/UpDownBase.cs @@ -354,6 +354,15 @@ public override void OnApplyTemplate() public void SelectAll() => _textBoxField?.SelectAll(); + public Visibility UpDownButtonsVisibility + { + get => (Visibility)GetValue(UpDownButtonsVisibilityProperty); + set => SetValue(UpDownButtonsVisibilityProperty, value); + } + + public static readonly DependencyProperty UpDownButtonsVisibilityProperty = + DependencyProperty.Register(nameof(UpDownButtonsVisibility), typeof(Visibility), typeof(UpDownBase), new PropertyMetadata(Visibility.Visible)); + public object? IncreaseContent { get => GetValue(IncreaseContentProperty); diff --git a/tests/MaterialDesignThemes.UITests/WPF/UpDownControls/DecimalUpDownTests.cs b/tests/MaterialDesignThemes.UITests/WPF/UpDownControls/DecimalUpDownTests.cs index 9556e4f85e..4598e9d7a7 100644 --- a/tests/MaterialDesignThemes.UITests/WPF/UpDownControls/DecimalUpDownTests.cs +++ b/tests/MaterialDesignThemes.UITests/WPF/UpDownControls/DecimalUpDownTests.cs @@ -229,4 +229,64 @@ public async Task LostFocusWhenTextIsInvalid_RevertsToOriginalValue(string input recorder.Success(); } + + [Test] + public async Task UpDownButtonsVisibility_Collapsed_RemovesReservedPadding() + { + await using var recorder = new TestRecorder(App); + + Thickness originalPadding = new(4, 5, 6, 7); + var decimalUpDown = await LoadXaml(""" + + """); + var buttonsHost = await decimalUpDown.GetElement("ButtonsHost"); + var textBox = await decimalUpDown.GetElement("PART_TextBox"); + + await Wait.For(async () => + { + await Assert.That(await buttonsHost.RemoteExecute(GetButtonsHostVisibility)).IsEqualTo(Visibility.Collapsed); + await Assert.That(await buttonsHost.RemoteExecute(GetButtonsHostActualWidth)).IsEqualTo(0d); + await Assert.That(await textBox.RemoteExecute(GetTextBoxPadding)).IsEqualTo(originalPadding); + }); + + recorder.Success(); + } + + [Test] + public async Task UpDownButtonsVisibility_Hidden_PreservesReservedPadding() + { + await using var recorder = new TestRecorder(App); + + Thickness originalPadding = new(4, 5, 6, 7); + var decimalUpDown = await LoadXaml(""" + + """); + var buttonsHost = await decimalUpDown.GetElement("ButtonsHost"); + var textBox = await decimalUpDown.GetElement("PART_TextBox"); + + await Wait.For(async () => + { + double buttonsWidth = await buttonsHost.RemoteExecute(GetButtonsHostActualWidth); + Thickness textBoxPadding = await textBox.RemoteExecute(GetTextBoxPadding); + + await Assert.That(await buttonsHost.RemoteExecute(GetButtonsHostVisibility)).IsEqualTo(Visibility.Hidden); + await Assert.That(buttonsWidth).IsGreaterThan(0d); + await Assert.That(textBoxPadding.Left).IsEqualTo(originalPadding.Left); + await Assert.That(textBoxPadding.Top).IsEqualTo(originalPadding.Top); + await Assert.That(textBoxPadding.Bottom).IsEqualTo(originalPadding.Bottom); + await Assert.That(textBoxPadding.Right).IsEqualTo(originalPadding.Right + buttonsWidth); + }); + + recorder.Success(); + } + + private static Visibility GetButtonsHostVisibility(StackPanel stackPanel) => stackPanel.Visibility; + + private static double GetButtonsHostActualWidth(StackPanel stackPanel) => stackPanel.ActualWidth; + + private static Thickness GetTextBoxPadding(TextBox textBox) => textBox.Padding; } diff --git a/tests/MaterialDesignThemes.Wpf.Tests/Converters/UpDownButtonsPaddingConverterTests.cs b/tests/MaterialDesignThemes.Wpf.Tests/Converters/UpDownButtonsPaddingConverterTests.cs new file mode 100644 index 0000000000..36ee829369 --- /dev/null +++ b/tests/MaterialDesignThemes.Wpf.Tests/Converters/UpDownButtonsPaddingConverterTests.cs @@ -0,0 +1,27 @@ +using System.Globalization; +using MaterialDesignThemes.Wpf.Converters.Internal; + +namespace MaterialDesignThemes.Wpf.Tests.Converters; + +public sealed class UpDownButtonsPaddingConverterTests +{ + [Test] + public async Task Convert_WhenButtonsVisible_AddsButtonsWidthToRightPadding() + { + UpDownButtonsPaddingConverter converter = new(); + + var result = (Thickness)converter.Convert([new Thickness(1, 2, 3, 4), 20d], typeof(Thickness), null!, CultureInfo.InvariantCulture); + + await Assert.That(result).IsEqualTo(new Thickness(1, 2, 23, 4)); + } + + [Test] + public async Task Convert_WhenButtonsWidthIsNotFinite_ReturnsOriginalPadding() + { + UpDownButtonsPaddingConverter converter = new(); + + var result = (Thickness)converter.Convert([new Thickness(1, 2, 3, 4), double.NaN], typeof(Thickness), null!, CultureInfo.InvariantCulture); + + await Assert.That(result).IsEqualTo(new Thickness(1, 2, 3, 4)); + } +}