diff --git a/XAMLTest.Tests/VisualElementTests.cs b/XAMLTest.Tests/VisualElementTests.cs index 1cbc83f0..0dab3bb4 100644 --- a/XAMLTest.Tests/VisualElementTests.cs +++ b/XAMLTest.Tests/VisualElementTests.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -310,4 +310,62 @@ static void ChangeTitle(Window window) window.Title = "Test Title"; } } + + [TestMethod] + public async Task MarkInvalid_WhenPropertyIsDependencyObjectWithoutExistingBinding_SetsValidationError() + { + // Arrange + await using TestRecorder recorder = new(App); + + const string expectedErrorMessage = "Custom validation error"; + await Window.SetXamlContent(@""); + IVisualElement textBox = await Window.GetElement("TextBox"); + + //Act (set validation) + await textBox.MarkInvalid(TextBox.TextProperty, expectedErrorMessage); + var result1 = await textBox.GetProperty(System.Windows.Controls.Validation.HasErrorProperty); + var validationError1 = await textBox.GetValidationErrorContent(TextBox.TextProperty); + + //Act (clear validation) + await textBox.ClearInvalid(TextBox.TextProperty); + var result2 = await textBox.GetProperty(System.Windows.Controls.Validation.HasErrorProperty); + var validationError2 = await textBox.GetValidationErrorContent(TextBox.TextProperty); + + //Assert + Assert.AreEqual(true, result1); + Assert.AreEqual(expectedErrorMessage, validationError1); + Assert.AreEqual(false, result2); + Assert.IsNull(validationError2); + + recorder.Success(); + } + + [TestMethod] + public async Task MarkInvalid_WhenPropertyIsDependencyObjectWithExistingBinding_SetsValidationError() + { + // Arrange + await using TestRecorder recorder = new(App); + + const string expectedErrorMessage = "Custom validation error"; + await Window.SetXamlContent(@""); + IVisualElement textBox = await Window.GetElement("TextBox"); + + //Act (set validation) + await textBox.MarkInvalid(TextBox.TextProperty, expectedErrorMessage); + var result1 = await textBox.GetProperty(System.Windows.Controls.Validation.HasErrorProperty); + var validationError1 = await textBox.GetValidationErrorContent(TextBox.TextProperty); + + //Act (clear validation) + await textBox.ClearInvalid(TextBox.TextProperty); + var result2 = await textBox.GetProperty(System.Windows.Controls.Validation.HasErrorProperty); + var validationError2 = await textBox.GetValidationErrorContent(TextBox.TextProperty); + + //Assert + Assert.AreEqual(true, result1); + Assert.AreEqual(expectedErrorMessage, validationError1); + Assert.AreEqual(false, result2); + Assert.IsNull(validationError2); + + recorder.Success(); + } } diff --git a/XAMLTest/Host/VisualTreeService.cs b/XAMLTest/Host/VisualTreeService.cs index e41baaaa..35b9f4e8 100644 --- a/XAMLTest/Host/VisualTreeService.cs +++ b/XAMLTest/Host/VisualTreeService.cs @@ -14,6 +14,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; +using System.Windows.Data; using System.Windows.Markup; using System.Windows.Media; using XamlTest.Internal; @@ -275,6 +276,131 @@ await Application.Dispatcher.InvokeAsync(() => } } + public override async Task MarkInvalid(MarkInvalidRequest request, ServerCallContext context) + { + MarkInvalidResult reply = new(); + await Application.Dispatcher.InvokeAsync(() => + { + try + { + DependencyObject? element = GetCachedElement(request.ElementId); + if (element is null) + { + reply.ErrorMessages.Add("Could not find element"); + return; + } + + if (!string.IsNullOrWhiteSpace(request.OwnerType)) + { + if (DependencyPropertyHelper.TryGetDependencyProperty(request.Name, request.OwnerType, out DependencyProperty? dependencyProperty)) + { + BindingExpressionBase? bindingExpression = BindingOperations.GetBindingExpression(element, dependencyProperty); + if (bindingExpression == null) + { + var binding = new Binding + { + Path = new PropertyPath(ValidationErrorDummyProperty), + RelativeSource = new RelativeSource(RelativeSourceMode.Self) + }; + bindingExpression = BindingOperations.SetBinding(element, dependencyProperty, binding); + } + var validationError = new ValidationError(new DummyValidationRule(request.ValidationError), bindingExpression) + { + ErrorContent = request.ValidationError + }; + System.Windows.Controls.Validation.MarkInvalid(bindingExpression, validationError); + } + else + { + reply.ErrorMessages.Add($"Could not find dependency property '{request.Name}' on '{request.OwnerType}'"); + } + } + } + catch (Exception e) + { + reply.ErrorMessages.Add(e.ToString()); + } + }); + return reply; + } + + public override async Task ClearInvalid(ClearInvalidRequest request, ServerCallContext context) + { + ClearInvalidResult reply = new(); + await Application.Dispatcher.InvokeAsync(() => + { + try + { + DependencyObject? element = GetCachedElement(request.ElementId); + if (element is null) + { + reply.ErrorMessages.Add("Could not find element"); + return; + } + + if (!string.IsNullOrWhiteSpace(request.OwnerType)) + { + if (DependencyPropertyHelper.TryGetDependencyProperty(request.Name, request.OwnerType, out DependencyProperty? dependencyProperty)) + { + var bindingExpression = BindingOperations.GetBindingExpression(element, dependencyProperty); + if (bindingExpression != null) + { + // Clear the invalidation + System.Windows.Controls.Validation.ClearInvalid(bindingExpression); + } + } + else + { + reply.ErrorMessages.Add($"Could not find dependency property '{request.Name}' on '{request.OwnerType}'"); + } + } + } + catch (Exception e) + { + reply.ErrorMessages.Add(e.ToString()); + } + }); + return reply; + } + + public override async Task GetValidationErrorContent(GetValidationErrorContentRequest request, ServerCallContext context) + { + GetValidationErrorContentResult reply = new(); + await Application.Dispatcher.InvokeAsync(() => + { + try + { + DependencyObject? element = GetCachedElement(request.ElementId); + if (element is null) + { + reply.ErrorMessages.Add("Could not find element"); + return; + } + + if (!string.IsNullOrWhiteSpace(request.OwnerType)) + { + if (DependencyPropertyHelper.TryGetDependencyProperty(request.Name, request.OwnerType, out DependencyProperty? dependencyProperty)) + { + var errors = System.Windows.Controls.Validation.GetErrors(element); + if (errors.Any() && errors[0].ErrorContent is string errorContent) + { + reply.Value = errorContent; + } + } + else + { + reply.ErrorMessages.Add($"Could not find dependency property '{request.Name}' on '{request.OwnerType}'"); + } + } + } + catch (Exception e) + { + reply.ErrorMessages.Add(e.ToString()); + } + }); + return reply; + } + public override async Task SetXamlProperty(SetXamlPropertyRequest request, ServerCallContext context) { ElementResult reply = new(); @@ -799,4 +925,35 @@ private static (double ScaleX, double ScaleY) GetScalingFromVisual(Visual visual } return (1.0, 1.0); } + + + // TODO The stuff below probably needs to go elsewhere. A simple internal attached DP used to create a binding when one is not present, and a custom ValidationRule to apply the validation error. + + internal static readonly DependencyProperty ValidationErrorDummyProperty = DependencyProperty.RegisterAttached( + "ValidationErrorDummy", typeof(object), typeof(VisualTreeService), new PropertyMetadata(default(object))); + + internal static void SetValidationErrorDummy(DependencyObject element, object value) + { + element.SetValue(ValidationErrorDummyProperty, value); + } + + internal static object GetValidationErrorDummy(DependencyObject element) + { + return (object)element.GetValue(ValidationErrorDummyProperty); + } + + internal class DummyValidationRule : ValidationRule + { + private readonly string _validationError; + + public DummyValidationRule(string validationError) + { + _validationError = validationError; + } + + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + return new ValidationResult(false, _validationError); + } + } } diff --git a/XAMLTest/Host/XamlTestSpec.proto b/XAMLTest/Host/XamlTestSpec.proto index dbe086cd..c94856cb 100644 --- a/XAMLTest/Host/XamlTestSpec.proto +++ b/XAMLTest/Host/XamlTestSpec.proto @@ -30,7 +30,16 @@ service Protocol { //Sets a property rpc SetProperty (SetPropertyRequest) returns (PropertyResult); + + //Marks a property as having a validation error + rpc MarkInvalid (MarkInvalidRequest) returns (MarkInvalidResult); + + //Clear a validation error from a property + rpc ClearInvalid (ClearInvalidRequest) returns (ClearInvalidResult); + //Marks a property as having a validation error + rpc GetValidationErrorContent (GetValidationErrorContentRequest) returns (GetValidationErrorContentResult); + //Set a property with XAML content rpc SetXamlProperty (SetXamlPropertyRequest) returns (ElementResult); @@ -111,6 +120,25 @@ message PropertyResult { Element element = 5; } +message MarkInvalidResult { + repeated string errorMessages = 1; + string propertyType = 2; + Element element = 3; +} + +message GetValidationErrorContentResult { + repeated string errorMessages = 1; + string value = 2; + string valueType = 3; + string propertyType = 4; +} + +message ClearInvalidResult { + repeated string errorMessages = 1; + string propertyType = 2; + Element element = 3; +} + message EffectiveBackgroundQuery { string elementId = 1; string toElementId = 2; @@ -132,6 +160,28 @@ message SetPropertyRequest { string ownerType = 5; } +message MarkInvalidRequest { + string elementId = 1; + string name = 2; + string validationError = 3; + string valueType = 4; + string ownerType = 5; +} + +message ClearInvalidRequest { + string elementId = 1; + string name = 2; + string valueType = 3; + string ownerType = 4; +} + +message GetValidationErrorContentRequest { + string elementId = 1; + string name = 2; + string valueType = 3; + string ownerType = 4; +} + message XamlNamespace { string prefix = 1; string uri = 2; diff --git a/XAMLTest/IVisualElement.cs b/XAMLTest/IVisualElement.cs index ca0a761e..ff95a8cc 100644 --- a/XAMLTest/IVisualElement.cs +++ b/XAMLTest/IVisualElement.cs @@ -56,6 +56,34 @@ public interface IVisualElement : IEquatable /// Task SetProperty(string name, string value, string? valueType, string? ownerType); + /// + /// Marks a property as invalid using an internal (XAMLTest specific) validation rule. + /// + /// The name of the property + /// The validation error to set on the property binding + /// The assembly qualified type of the value + /// The assembly qualified name of the type that owns the property + /// + Task MarkInvalid(string name, string? validationError, string? valueType, string? ownerType); + + /// + /// Clears validation errors for a property (if any). + /// + /// The name of the property + /// The assembly qualified type of the value + /// The assembly qualified name of the type that owns the property + /// + Task ClearInvalid(string name, string? valueType, string? ownerType); + + /// + /// Gets the validation error content for a property (if any). + /// + /// The name of the property + /// The assembly qualified type of the value + /// The assembly qualified name of the type that owns the property + /// + Task GetValidationErrorContent(string name, string? valueType, string? ownerType); + /// /// Sets the value of a property by loading XAML content. /// diff --git a/XAMLTest/Internal/VisualElement.cs b/XAMLTest/Internal/VisualElement.cs index 40c17d07..54901b40 100644 --- a/XAMLTest/Internal/VisualElement.cs +++ b/XAMLTest/Internal/VisualElement.cs @@ -145,6 +145,82 @@ public async Task SetProperty(string name, string value, string? valueTy throw new XAMLTestException("Failed to receive a reply"); } + public async Task MarkInvalid(string name, string? validationError, string? valueType, string? ownerType) + { + MarkInvalidRequest query = new() + { + ElementId = Id, + Name = name, + ValidationError = validationError, + ValueType = valueType, + OwnerType = ownerType ?? "" + }; + LogMessage?.Invoke($"{nameof(MarkInvalid)}({name},{validationError},{valueType},{ownerType})"); + if (await Client.MarkInvalidAsync(query) is { } reply) + { + if (reply.ErrorMessages.Any()) + { + throw new XAMLTestException(string.Join(Environment.NewLine, reply.ErrorMessages)); + } + if (reply.PropertyType is { } propertyType) + { + return new Property(propertyType, "bool", true, null, Context); + } + throw new XAMLTestException("Property reply does not have a type specified"); + } + throw new XAMLTestException("Failed to receive a reply"); + } + + public async Task ClearInvalid(string name, string? valueType, string? ownerType) + { + ClearInvalidRequest query = new() + { + ElementId = Id, + Name = name, + ValueType = valueType, + OwnerType = ownerType ?? "" + }; + LogMessage?.Invoke($"{nameof(ClearInvalid)}({name},{valueType},{ownerType})"); + if (await Client.ClearInvalidAsync(query) is { } reply) + { + if (reply.ErrorMessages.Any()) + { + throw new XAMLTestException(string.Join(Environment.NewLine, reply.ErrorMessages)); + } + if (reply.PropertyType is { } propertyType) + { + return new Property(propertyType, "bool", true, null, Context); + } + throw new XAMLTestException("Property reply does not have a type specified"); + } + throw new XAMLTestException("Failed to receive a reply"); + } + + public async Task GetValidationErrorContent(string name, string? valueType, string? ownerType) + { + GetValidationErrorContentRequest query = new() + { + ElementId = Id, + Name = name, + ValueType = valueType, + OwnerType = ownerType ?? "" + }; + LogMessage?.Invoke($"{nameof(GetValidationErrorContent)}({name},{valueType},{ownerType})"); + if (await Client.GetValidationErrorContentAsync(query) is { } reply) + { + if (reply.ErrorMessages.Any()) + { + throw new XAMLTestException(string.Join(Environment.NewLine, reply.ErrorMessages)); + } + if (reply.PropertyType is { } propertyType) + { + return new Property(propertyType, "string", reply.Value, null, Context); + } + throw new XAMLTestException("Property reply does not have a type specified"); + } + throw new XAMLTestException("Failed to receive a reply"); + } + public Task SetXamlProperty(string propertyName, XamlSegment xaml) => SetXamlProperty(propertyName, xaml, null); diff --git a/XAMLTest/VisualElementMixins.cs b/XAMLTest/VisualElementMixins.cs index 1303c983..9da087b8 100644 --- a/XAMLTest/VisualElementMixins.cs +++ b/XAMLTest/VisualElementMixins.cs @@ -94,4 +94,60 @@ public static async Task SetProperty(this IVisualElement element, } return default; } + + public static async Task MarkInvalid(this IVisualElement element, DependencyProperty dependencyProperty, string validationError) + { + if (element is null) + { + throw new ArgumentNullException(nameof(element)); + } + + if (dependencyProperty is null) + { + throw new ArgumentNullException(nameof(dependencyProperty)); + } + + IValue result = await element.MarkInvalid(dependencyProperty.Name, validationError, typeof(bool).AssemblyQualifiedName, dependencyProperty.OwnerType.AssemblyQualifiedName); + if (result is { }) + { + return true; + } + return false; + } + + public static async Task ClearInvalid(this IVisualElement element, DependencyProperty dependencyProperty) + { + if (element is null) + { + throw new ArgumentNullException(nameof(element)); + } + + if (dependencyProperty is null) + { + throw new ArgumentNullException(nameof(dependencyProperty)); + } + + IValue result = await element.ClearInvalid(dependencyProperty.Name, typeof(bool).AssemblyQualifiedName, dependencyProperty.OwnerType.AssemblyQualifiedName); + if (result is { }) + { + return true; + } + return false; + } + + public static async Task GetValidationErrorContent(this IVisualElement element, DependencyProperty dependencyProperty) + { + if (element is null) + { + throw new ArgumentNullException(nameof(element)); + } + + if (dependencyProperty is null) + { + throw new ArgumentNullException(nameof(dependencyProperty)); + } + + IValue result = await element.GetValidationErrorContent(dependencyProperty.Name, typeof(T).AssemblyQualifiedName, dependencyProperty.OwnerType.AssemblyQualifiedName); + return result.GetAs(); + } }