From bada3ce2fd83d6cc5fa70c740cb61de167aa1022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 14 Jul 2026 23:58:29 +0200 Subject: [PATCH] Handle non-convertible trigger values --- .../interactions/data-trigger-behavior.md | 2 + .../Helpers/ComparisonConditionTypeHelper.cs | 43 ++++++++++--- .../Core/DataTriggerBehaviorTests.cs | 33 ++++++++++ .../ComparisonConditionTypeHelperTests.cs | 63 +++++++++++++++++++ 4 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 tests/Xaml.Behaviors.Interactivity.UnitTests/ComparisonConditionTypeHelperTests.cs diff --git a/docfx/articles/interactions/data-trigger-behavior.md b/docfx/articles/interactions/data-trigger-behavior.md index 775026bc0..f7cee8f80 100644 --- a/docfx/articles/interactions/data-trigger-behavior.md +++ b/docfx/articles/interactions/data-trigger-behavior.md @@ -1,5 +1,7 @@ # DataTriggerBehavior +When a string `Value` cannot be converted to the runtime type of `Binding`, the comparison uses non-equal semantics instead of throwing: `NotEqual` evaluates to `true`, while the other comparison operators evaluate to `false`. + `DataTriggerBehavior` is a behavior that listens for changes to a bound value and invokes actions when the value meets a specified condition. ## Properties diff --git a/src/Xaml.Behaviors.Interactivity/Helpers/ComparisonConditionTypeHelper.cs b/src/Xaml.Behaviors.Interactivity/Helpers/ComparisonConditionTypeHelper.cs index ccaa656b1..11c02c719 100644 --- a/src/Xaml.Behaviors.Interactivity/Helpers/ComparisonConditionTypeHelper.cs +++ b/src/Xaml.Behaviors.Interactivity/Helpers/ComparisonConditionTypeHelper.cs @@ -16,7 +16,16 @@ public static bool Compare(object? leftOperand, ComparisonConditionType operator if (rightOperand is string rightOperandString) { var leftOperandType = leftOperand.GetType(); - var convertedRightOperand = TypeConverterHelper.Convert(rightOperandString, leftOperandType); + object? convertedRightOperand = null; + try + { + convertedRightOperand = TypeConverterHelper.Convert(rightOperandString, leftOperandType); + } + catch (Exception exception) when (IsConversionException(exception)) + { + // Keep the original operand so comparable evaluation can use non-equal semantics. + } + if (convertedRightOperand is not null) { rightOperand = convertedRightOperand; @@ -74,21 +83,26 @@ private static bool EvaluateComparable(IComparable leftOperand, ComparisonCondit { convertedOperand = Convert.ChangeType(rightOperand, leftOperand.GetType(), CultureInfo.CurrentCulture); } - catch (FormatException) + catch (Exception exception) when (IsConversionException(exception)) { - // FormatException: Convert.ChangeType("hello", typeof(double), ...); + // The operands cannot be converted to a common comparable type. } - catch (InvalidCastException) + + if (convertedOperand is null) { - // InvalidCastException: Convert.ChangeType(4.0d, typeof(Rectangle), ...); + return IsNonEqualResult(operatorType); } - if (convertedOperand is null) + int comparison; + try { - return operatorType == ComparisonConditionType.NotEqual; + comparison = leftOperand.CompareTo((IComparable)convertedOperand); + } + catch (Exception exception) when (exception is ArgumentException or InvalidCastException) + { + return IsNonEqualResult(operatorType); } - var comparison = leftOperand.CompareTo((IComparable)convertedOperand); return operatorType switch { ComparisonConditionType.Equal => comparison == 0, @@ -100,4 +114,17 @@ private static bool EvaluateComparable(IComparable leftOperand, ComparisonCondit _ => false }; } + + private static bool IsConversionException(Exception exception) + { + return exception is FormatException + or InvalidCastException + or NotSupportedException + or OverflowException; + } + + private static bool IsNonEqualResult(ComparisonConditionType operatorType) + { + return operatorType == ComparisonConditionType.NotEqual; + } } diff --git a/tests/Xaml.Behaviors.Interactions.UnitTests/Core/DataTriggerBehaviorTests.cs b/tests/Xaml.Behaviors.Interactions.UnitTests/Core/DataTriggerBehaviorTests.cs index adf0bd68c..667e5b7b3 100644 --- a/tests/Xaml.Behaviors.Interactions.UnitTests/Core/DataTriggerBehaviorTests.cs +++ b/tests/Xaml.Behaviors.Interactions.UnitTests/Core/DataTriggerBehaviorTests.cs @@ -1,6 +1,11 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Headless.XUnit; using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.Xaml.Interactions.Core; +using Avalonia.Xaml.Interactivity; using Xunit; namespace Avalonia.Xaml.Interactions.UnitTests.Core; @@ -70,4 +75,32 @@ public void DataTriggerBehavior_003() Assert.Equal("50 or more", window.TargetTextBlock.Text); Assert.Equal(50d, window.TargetSlider.Value); } + + [AvaloniaFact] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Validates the reflection-based compatibility trigger.")] + public void DataTriggerBehavior_NonConvertibleValue_DoesNotThrowOrExecuteActions() + { + var commandCalls = 0; + var target = new Border(); + var trigger = new DataTriggerBehavior + { + Binding = 42, + ComparisonCondition = ComparisonConditionType.Equal, + Value = "not-an-integer", + }; + var action = new InvokeCommandAction + { + Command = new Command(_ => commandCalls++), + }; + trigger.Actions ??= []; + trigger.Actions.Add(action); + Interaction.GetBehaviors(target).Add(trigger); + var window = new Window { Content = target }; + window.Show(); + + var exception = Record.Exception(() => Dispatcher.UIThread.RunJobs()); + + Assert.Null(exception); + Assert.Equal(0, commandCalls); + } } diff --git a/tests/Xaml.Behaviors.Interactivity.UnitTests/ComparisonConditionTypeHelperTests.cs b/tests/Xaml.Behaviors.Interactivity.UnitTests/ComparisonConditionTypeHelperTests.cs new file mode 100644 index 000000000..115130d58 --- /dev/null +++ b/tests/Xaml.Behaviors.Interactivity.UnitTests/ComparisonConditionTypeHelperTests.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Xunit; + +namespace Avalonia.Xaml.Interactivity.UnitTests; + +[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Validates the reflection-based compatibility comparison helper.")] +public class ComparisonConditionTypeHelperTests +{ + private sealed class ThrowingComparable : IComparable + { + public int CompareCalls { get; private set; } + + public int CompareTo(object? obj) + { + CompareCalls++; + throw new ArgumentException("Operands are not comparable.", nameof(obj)); + } + } + + [Theory] + [InlineData(ComparisonConditionType.Equal, false)] + [InlineData(ComparisonConditionType.NotEqual, true)] + [InlineData(ComparisonConditionType.LessThan, false)] + [InlineData(ComparisonConditionType.LessThanOrEqual, false)] + [InlineData(ComparisonConditionType.GreaterThan, false)] + [InlineData(ComparisonConditionType.GreaterThanOrEqual, false)] + public void Compare_NonConvertibleString_UsesNonEqualSemantics( + ComparisonConditionType operatorType, + bool expected) + { + var result = ComparisonConditionTypeHelper.Compare(42, operatorType, "not-an-integer"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Compare_OverflowingString_UsesNonEqualSemantics() + { + var result = ComparisonConditionTypeHelper.Compare( + 42, + ComparisonConditionType.Equal, + "999999999999999999999999999999"); + + Assert.False(result); + } + + [Theory] + [InlineData(ComparisonConditionType.Equal, false)] + [InlineData(ComparisonConditionType.NotEqual, true)] + public void Compare_IncompatibleComparable_UsesNonEqualSemantics( + ComparisonConditionType operatorType, + bool expected) + { + var left = new ThrowingComparable(); + var right = new ThrowingComparable(); + + var result = ComparisonConditionTypeHelper.Compare(left, operatorType, right); + + Assert.Equal(expected, result); + Assert.Equal(1, left.CompareCalls); + } +}