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();
+ }
}