diff --git a/samples/BindingTest/BindingTest.csproj b/samples/BindingTest/BindingTest.csproj index 0cdb826e11..a1d79472d8 100644 --- a/samples/BindingTest/BindingTest.csproj +++ b/samples/BindingTest/BindingTest.csproj @@ -50,6 +50,7 @@ True + ..\..\packages\System.Reactive.Core.3.0.0\lib\net45\System.Reactive.Core.dll @@ -80,7 +81,9 @@ TestItemView.xaml - + + + diff --git a/samples/BindingTest/MainWindow.xaml b/samples/BindingTest/MainWindow.xaml index 149625925a..02c364346d 100644 --- a/samples/BindingTest/MainWindow.xaml +++ b/samples/BindingTest/MainWindow.xaml @@ -70,9 +70,19 @@ - + - + + + + + + + + + + + diff --git a/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs b/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs new file mode 100644 index 0000000000..634498c165 --- /dev/null +++ b/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs @@ -0,0 +1,17 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System.ComponentModel.DataAnnotations; + +namespace BindingTest.ViewModels +{ + public class DataAnnotationsErrorViewModel + { + [Phone] + [MaxLength(10)] + public string PhoneNumber { get; set; } + + [Range(0, 9)] + public int LessThan10 { get; set; } + } +} diff --git a/samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs b/samples/BindingTest/ViewModels/ExceptionErrorViewModel.cs similarity index 79% rename from samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs rename to samples/BindingTest/ViewModels/ExceptionErrorViewModel.cs index 01155f1d9f..e6071e0678 100644 --- a/samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs +++ b/samples/BindingTest/ViewModels/ExceptionErrorViewModel.cs @@ -6,7 +6,7 @@ using System; namespace BindingTest.ViewModels { - public class ExceptionPropertyErrorViewModel : ReactiveObject + public class ExceptionErrorViewModel : ReactiveObject { private int _lessThan10; @@ -21,7 +21,7 @@ namespace BindingTest.ViewModels } else { - throw new InvalidOperationException("Value must be less than 10."); + throw new ArgumentOutOfRangeException("Value must be less than 10."); } } } diff --git a/samples/BindingTest/ViewModels/IndeiErrorViewModel.cs b/samples/BindingTest/ViewModels/IndeiErrorViewModel.cs new file mode 100644 index 0000000000..b4bb528abb --- /dev/null +++ b/samples/BindingTest/ViewModels/IndeiErrorViewModel.cs @@ -0,0 +1,73 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using ReactiveUI; +using System; +using System.ComponentModel; +using System.Collections; + +namespace BindingTest.ViewModels +{ + public class IndeiErrorViewModel : ReactiveObject, INotifyDataErrorInfo + { + private int _maximum = 10; + private int _value; + private string _valueError; + + public IndeiErrorViewModel() + { + this.WhenAnyValue(x => x.Maximum, x => x.Value) + .Subscribe(_ => UpdateErrors()); + } + + public bool HasErrors + { + get { throw new NotImplementedException(); } + } + + public int Maximum + { + get { return _maximum; } + set { this.RaiseAndSetIfChanged(ref _maximum, value); } + } + + public int Value + { + get { return _value; } + set { this.RaiseAndSetIfChanged(ref _value, value); } + } + + public event EventHandler ErrorsChanged; + + public IEnumerable GetErrors(string propertyName) + { + switch (propertyName) + { + case nameof(Value): + return new[] { _valueError }; + default: + return null; + } + } + + private void UpdateErrors() + { + if (Value <= Maximum) + { + if (_valueError != null) + { + _valueError = null; + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + } + } + else + { + if (_valueError == null) + { + _valueError = "Value must be less than Maximum"; + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + } + } + } + } +} diff --git a/samples/BindingTest/ViewModels/MainWindowViewModel.cs b/samples/BindingTest/ViewModels/MainWindowViewModel.cs index 6fbfb8a23f..94f7ff595a 100644 --- a/samples/BindingTest/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingTest/ViewModels/MainWindowViewModel.cs @@ -69,7 +69,8 @@ namespace BindingTest.ViewModels public ReactiveCommand StringValueCommand { get; } - public ExceptionPropertyErrorViewModel ExceptionPropertyValidation { get; } - = new ExceptionPropertyErrorViewModel(); + public DataAnnotationsErrorViewModel DataAnnotationsValidation { get; } = new DataAnnotationsErrorViewModel(); + public ExceptionErrorViewModel ExceptionDataValidation { get; } = new ExceptionErrorViewModel(); + public IndeiErrorViewModel IndeiDataValidation { get; } = new IndeiErrorViewModel(); } } diff --git a/samples/interop/GtkInteropDemo/GtkInteropDemo.v2.ncrunchproject b/samples/interop/GtkInteropDemo/GtkInteropDemo.v2.ncrunchproject new file mode 100644 index 0000000000..e1b4d7cf28 --- /dev/null +++ b/samples/interop/GtkInteropDemo/GtkInteropDemo.v2.ncrunchproject @@ -0,0 +1,26 @@ + + true + 1000 + false + false + false + true + false + false + true + false + false + false + true + false + true + true + true + 60000 + + + + AutoDetect + STA + x86 + \ No newline at end of file diff --git a/samples/interop/WindowsInteropTest/WindowsInteropTest.v2.ncrunchproject b/samples/interop/WindowsInteropTest/WindowsInteropTest.v2.ncrunchproject new file mode 100644 index 0000000000..e1b4d7cf28 --- /dev/null +++ b/samples/interop/WindowsInteropTest/WindowsInteropTest.v2.ncrunchproject @@ -0,0 +1,26 @@ + + true + 1000 + false + false + false + true + false + false + true + false + false + false + true + false + true + true + true + 60000 + + + + AutoDetect + STA + x86 + \ No newline at end of file diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index 1dfc5cf65b..887d3ff9bd 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -43,10 +43,9 @@ Properties\SharedAssemblyInfo.cs - + + - - @@ -116,9 +115,11 @@ + + diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index e6b88c71f8..eeaf782e83 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -50,29 +50,6 @@ namespace Avalonia /// private EventHandler _propertyChanged; - /// - /// Defines the property. - /// - public static readonly DirectProperty ValidationStatusProperty = - AvaloniaProperty.RegisterDirect(nameof(ValidationStatus), c => c.ValidationStatus); - - private ObjectValidationStatus validationStatus; - - /// - /// The current validation status of the control. - /// - public ObjectValidationStatus ValidationStatus - { - get - { - return validationStatus; - } - private set - { - SetAndRaise(ValidationStatusProperty, ref validationStatus, value); - } - } - /// /// Initializes a new instance of the class. /// @@ -251,6 +228,10 @@ namespace Avalonia /// /// The property. /// True if the property is set, otherwise false. + /// + /// Checks whether a value is assigned to the property, or that there is a binding to the + /// property that is producing a value other than . + /// public bool IsSet(AvaloniaProperty property) { Contract.Requires(property != null); @@ -281,42 +262,11 @@ namespace Avalonia if (property.IsDirect) { - var accessor = (IDirectPropertyAccessor)GetRegistered(property); - LogPropertySet(property, value, priority); - accessor.SetValue(this, DirectUnsetToDefault(value, property)); + SetDirectValue(property, value); } else { - PriorityValue v; - var originalValue = value; - - if (!AvaloniaPropertyRegistry.Instance.IsRegistered(this, property)) - { - ThrowNotRegistered(property); - } - - if (!TypeUtilities.TryCast(property.PropertyType, value, out value)) - { - throw new ArgumentException(string.Format( - "Invalid value for Property '{0}': '{1}' ({2})", - property.Name, - originalValue, - originalValue?.GetType().FullName ?? "(null)")); - } - - if (!_values.TryGetValue(property, out v)) - { - if (value == AvaloniaProperty.UnsetValue) - { - return; - } - - v = CreatePriorityValue(property); - _values.Add(property, v); - } - - LogPropertySet(property, value, priority); - v.SetValue(value, (int)priority); + SetStyledValue(property, value, priority); } } @@ -371,7 +321,6 @@ namespace Avalonia GetDescription(source)); IDisposable subscription = null; - IDisposable validationSubcription = null; if (_directBindings == null) { @@ -379,19 +328,14 @@ namespace Avalonia } subscription = source - .Where(x => !(x is IValidationStatus)) .Select(x => CastOrDefault(x, property.PropertyType)) .Do(_ => { }, () => _directBindings.Remove(subscription)) - .Subscribe(x => DirectBindingSet(property, x)); - validationSubcription = source - .OfType() - .Subscribe(x => DataValidationChanged(property, x)); + .Subscribe(x => SetDirectValue(property, x)); _directBindings.Add(subscription); return Disposable.Create(() => { - validationSubcription.Dispose(); subscription.Dispose(); _directBindings.Remove(subscription); }); @@ -487,28 +431,9 @@ namespace Avalonia } /// - void IPriorityValueOwner.DataValidationChanged(PriorityValue sender, IValidationStatus status) + void IPriorityValueOwner.BindingNotificationReceived(PriorityValue sender, BindingNotification notification) { - var property = sender.Property; - DataValidationChanged(property, status); - } - - /// - /// Called when the validation state on a tracked property is changed. - /// - /// The property whose validation state changed. - /// The new validation state. - protected virtual void DataValidationChanged(AvaloniaProperty property, IValidationStatus status) - { - } - - /// - /// Updates the validation status of the current object. - /// - /// The new validation status. - protected void UpdateValidationState(IValidationStatus status) - { - ValidationStatus = ValidationStatus.UpdateValidationStatus(status); + UpdateDataValidation(sender.Property, notification); } /// @@ -542,6 +467,18 @@ namespace Avalonia }); } + /// + /// Called to update the validation state for properties for which data validation is + /// enabled. + /// + /// The property. + /// The new validation status. + protected virtual void UpdateDataValidation( + AvaloniaProperty property, + BindingNotification status) + { + } + /// /// Called when a avalonia property changes on the object. /// @@ -623,22 +560,27 @@ namespace Avalonia /// /// Tries to cast a value to a type, taking into account that the value may be a - /// . + /// . /// /// The value. /// The type. - /// The cast value, or a . + /// The cast value, or a . private static object CastOrDefault(object value, Type type) { - var error = value as BindingError; + var notification = value as BindingNotification; - if (error == null) + if (notification == null) { return TypeUtilities.CastOrDefault(value, type); } else { - return error; + if (notification.HasValue) + { + notification.SetValue(TypeUtilities.CastOrDefault(notification.Value, type)); + } + + return notification; } } @@ -666,50 +608,6 @@ namespace Avalonia return result; } - /// - /// Sets a property value for a direct property binding. - /// - /// The property. - /// The value. - /// - private void DirectBindingSet(AvaloniaProperty property, object value) - { - var error = value as BindingError; - - if (error == null) - { - SetValue(property, value); - } - else - { - if (error.UseFallbackValue) - { - SetValue(property, error.FallbackValue); - } - - Logger.Error( - LogArea.Binding, - this, - "Error binding to {Target}.{Property}: {Message}", - this, - property, - error.Exception.Message); - } - } - - /// - /// Converts an unset value to the default value for a direct property. - /// - /// The value. - /// The property. - /// The value. - private object DirectUnsetToDefault(object value, AvaloniaProperty property) - { - return value == AvaloniaProperty.UnsetValue ? - ((IDirectPropertyMetadata)property.GetMetadata(GetType())).UnsetValue : - value; - } - /// /// Gets the default value for a property. /// @@ -753,6 +651,109 @@ namespace Avalonia return result; } + /// + /// Sets the value of a direct property. + /// + /// The property. + /// The value. + private void SetDirectValue(AvaloniaProperty property, object value) + { + var notification = value as BindingNotification; + + if (notification != null) + { + if (notification.ErrorType == BindingErrorType.Error) + { + Logger.Error( + LogArea.Binding, + this, + "Error in binding to {Target}.{Property}: {Message}", + this, + property, + ExceptionUtilities.GetMessage(notification.Error)); + } + + if (notification.HasValue) + { + value = notification.Value; + } + } + + if (notification == null || notification.HasValue) + { + var metadata = (IDirectPropertyMetadata)property.GetMetadata(GetType()); + var accessor = (IDirectPropertyAccessor)GetRegistered(property); + var finalValue = value == AvaloniaProperty.UnsetValue ? + metadata.UnsetValue : value; + + LogPropertySet(property, value, BindingPriority.LocalValue); + + accessor.SetValue(this, finalValue); + } + + if (notification != null) + { + UpdateDataValidation(property, notification); + } + } + + /// + /// Sets the value of a styled property. + /// + /// The property. + /// The value. + /// The priority of the value. + private void SetStyledValue(AvaloniaProperty property, object value, BindingPriority priority) + { + var notification = value as BindingNotification; + + // We currently accept BindingNotifications for non-direct properties but we just + // strip them to their underlying value. + if (notification != null) + { + if (!notification.HasValue) + { + return; + } + else + { + value = notification.Value; + } + } + + var originalValue = value; + + if (!AvaloniaPropertyRegistry.Instance.IsRegistered(this, property)) + { + ThrowNotRegistered(property); + } + + if (!TypeUtilities.TryCast(property.PropertyType, value, out value)) + { + throw new ArgumentException(string.Format( + "Invalid value for Property '{0}': '{1}' ({2})", + property.Name, + originalValue, + originalValue?.GetType().FullName ?? "(null)")); + } + + PriorityValue v; + + if (!_values.TryGetValue(property, out v)) + { + if (value == AvaloniaProperty.UnsetValue) + { + return; + } + + v = CreatePriorityValue(property); + _values.Add(property, v); + } + + LogPropertySet(property, value, priority); + v.SetValue(value, (int)priority); + } + /// /// Given a returns a registered avalonia property that is /// equal or throws if not found. diff --git a/src/Avalonia.Base/AvaloniaObjectExtensions.cs b/src/Avalonia.Base/AvaloniaObjectExtensions.cs index 2751d8d5d5..3ca55529e6 100644 --- a/src/Avalonia.Base/AvaloniaObjectExtensions.cs +++ b/src/Avalonia.Base/AvaloniaObjectExtensions.cs @@ -216,7 +216,13 @@ namespace Avalonia Contract.Requires(property != null); Contract.Requires(binding != null); - var result = binding.Initiate(target, property, anchor); + var metadata = property.GetMetadata(target.GetType()) as IDirectPropertyMetadata; + + var result = binding.Initiate( + target, + property, + anchor, + metadata?.EnableDataValidation ?? false); if (result != null) { @@ -311,7 +317,8 @@ namespace Avalonia public InstancedBinding Initiate( IAvaloniaObject target, AvaloniaProperty targetProperty, - object anchor = null) + object anchor = null, + bool enableDataValidation = false) { return new InstancedBinding(_source); } diff --git a/src/Avalonia.Base/AvaloniaProperty.cs b/src/Avalonia.Base/AvaloniaProperty.cs index 0f5500b116..61006b1173 100644 --- a/src/Avalonia.Base/AvaloniaProperty.cs +++ b/src/Avalonia.Base/AvaloniaProperty.cs @@ -360,20 +360,25 @@ namespace Avalonia /// The value to use when the property is set to /// /// The default binding mode for the property. + /// + /// Whether the property is interested in data validation. + /// /// A public static DirectProperty RegisterDirect( string name, Func getter, Action setter = null, TValue unsetValue = default(TValue), - BindingMode defaultBindingMode = BindingMode.OneWay) + BindingMode defaultBindingMode = BindingMode.OneWay, + bool enableDataValidation = false) where TOwner : IAvaloniaObject { Contract.Requires(name != null); var metadata = new DirectPropertyMetadata( unsetValue: unsetValue, - defaultBindingMode: defaultBindingMode); + defaultBindingMode: defaultBindingMode, + enableDataValidation: enableDataValidation); var result = new DirectProperty(name, getter, setter, metadata); AvaloniaPropertyRegistry.Instance.Register(typeof(TOwner), result); diff --git a/src/Avalonia.Base/Data/BindingChainNullException.cs b/src/Avalonia.Base/Data/BindingChainNullException.cs new file mode 100644 index 0000000000..0e50a36d8a --- /dev/null +++ b/src/Avalonia.Base/Data/BindingChainNullException.cs @@ -0,0 +1,85 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Avalonia.Data +{ + /// + /// An exception returned through signalling that a + /// requested binding expression could not be evaluated because of a null in one of the links + /// of the binding chain. + /// + public class BindingChainNullException : Exception + { + private string _message; + + /// + /// Initalizes a new instance of the class. + /// + public BindingChainNullException() + { + } + + /// + /// Initalizes a new instance of the class. + /// + public BindingChainNullException(string message) + { + _message = message; + } + + /// + /// Initalizes a new instance of the class. + /// + /// The expression. + /// + /// The point in the expression at which the null was encountered. + /// + public BindingChainNullException(string expression, string expressionNullPoint) + { + Expression = expression; + ExpressionNullPoint = expressionNullPoint; + } + + /// + /// Gets the expression that could not be evaluated. + /// + public string Expression { get; protected set; } + + /// + /// Gets the point in the expression at which the null was encountered. + /// + public string ExpressionNullPoint { get; protected set; } + + /// + public override string Message + { + get + { + if (_message == null) + { + _message = BuildMessage(); + } + + return _message; + } + } + + private string BuildMessage() + { + if (Expression != null && ExpressionNullPoint != null) + { + return $"'{ExpressionNullPoint}' is null in expression '{Expression}'."; + } + else if (ExpressionNullPoint != null) + { + return $"'{ExpressionNullPoint}' is null in expression."; + } + else + { + return "Null encountered in binding expression."; + } + } + } +} diff --git a/src/Avalonia.Base/Data/BindingError.cs b/src/Avalonia.Base/Data/BindingError.cs deleted file mode 100644 index 5157586a49..0000000000 --- a/src/Avalonia.Base/Data/BindingError.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; - -namespace Avalonia.Data -{ - /// - /// Represents a recoverable binding error. - /// - /// - /// When produced by a binding source observable, informs the binding system that an error - /// occurred. It can also provide an optional fallback value to be pushed to the binding - /// target. - /// - /// Instead of using , one could simply not push a value (in the - /// case of a no fallback value) or push a fallback value, but BindingError also causes an - /// error to be logged with the correct binding target. - /// - public class BindingError - { - /// - /// Initializes a new instance of the class. - /// - /// An exception describing the binding error. - public BindingError(Exception exception) - { - Exception = exception; - } - - /// - /// Initializes a new instance of the class. - /// - /// An exception describing the binding error. - /// The fallback value. - public BindingError(Exception exception, object fallbackValue) - { - Exception = exception; - FallbackValue = fallbackValue; - UseFallbackValue = true; - } - - /// - /// Gets the exception describing the binding error. - /// - public Exception Exception { get; } - - /// - /// Get the fallback value. - /// - public object FallbackValue { get; } - - /// - /// Get a value indicating whether the fallback value should be pushed to the binding - /// target. - /// - public bool UseFallbackValue { get; } - } -} diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs new file mode 100644 index 0000000000..ecaf59e174 --- /dev/null +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -0,0 +1,282 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Avalonia.Data +{ + /// + /// Defines the types of binding errors for a . + /// + public enum BindingErrorType + { + /// + /// There was no error. + /// + None, + + /// + /// There was a binding error. + /// + Error, + + /// + /// There was a data validation error. + /// + DataValidationError, + } + + /// + /// Represents a binding notification that can be a valid binding value, or a binding or + /// data validation error. + /// + public class BindingNotification + { + /// + /// A binding notification representing the null value. + /// + public static readonly BindingNotification Null = + new BindingNotification(null); + + /// + /// A binding notification representing . + /// + public static readonly BindingNotification UnsetValue = + new BindingNotification(AvaloniaProperty.UnsetValue); + + // Null cannot be held in WeakReference as it's indistinguishable from an expired value so + // use this value in its place. + private static readonly object NullValue = new object(); + + private WeakReference _value; + + /// + /// Initializes a new instance of the class. + /// + /// The binding value. + public BindingNotification(object value) + { + _value = new WeakReference(value ?? NullValue); + } + + /// + /// Initializes a new instance of the class. + /// + /// The binding error. + /// The type of the binding error. + public BindingNotification(Exception error, BindingErrorType errorType) + { + if (errorType == BindingErrorType.None) + { + throw new ArgumentException($"'errorType' may not be None"); + } + + Error = error; + ErrorType = errorType; + } + + /// + /// Initializes a new instance of the class. + /// + /// The binding error. + /// The type of the binding error. + /// The fallback value. + public BindingNotification(Exception error, BindingErrorType errorType, object fallbackValue) + : this(error, errorType) + { + _value = new WeakReference(fallbackValue ?? NullValue); + } + + /// + /// Gets the value that should be passed to the target when + /// is true. + /// + /// + /// If this property is read when is false then it will return + /// . + /// + public object Value + { + get + { + if (_value != null) + { + object result; + + if (_value.TryGetTarget(out result)) + { + return result == NullValue ? null : result; + } + } + + // There's the possibility of a race condition in that HasValue can return true, + // and then the value is GC'd before Value is read. We should be ok though as + // we return UnsetValue which should be a safe alternative. + return AvaloniaProperty.UnsetValue; + } + } + + /// + /// Gets a value indicating whether should be pushed to the target. + /// + public bool HasValue => _value != null; + + /// + /// Gets the error that occurred on the source, if any. + /// + public Exception Error { get; set; } + + /// + /// Gets the type of error that represents, if any. + /// + public BindingErrorType ErrorType { get; set; } + + /// + /// Compares two instances of for equality. + /// + /// The first instance. + /// The second instance. + /// true if the two instances are equal; otherwise false. + public static bool operator ==(BindingNotification a, BindingNotification b) + { + if (object.ReferenceEquals(a, b)) + { + return true; + } + + if ((object)a == null || (object)b == null) + { + return false; + } + + return a.HasValue == b.HasValue && + a.ErrorType == b.ErrorType && + (!a.HasValue || object.Equals(a.Value, b.Value)) && + (a.ErrorType == BindingErrorType.None || ExceptionEquals(a.Error, b.Error)); + } + + /// + /// Compares two instances of for inequality. + /// + /// The first instance. + /// The second instance. + /// true if the two instances are unequal; otherwise false. + public static bool operator !=(BindingNotification a, BindingNotification b) + { + return !(a == b); + } + + /// + /// Gets a value from an object that may be a . + /// + /// The object. + /// The value. + /// + /// If is a then returns the binding + /// notification's . If not, returns the object unchanged. + /// + public static object ExtractValue(object o) + { + var notification = o as BindingNotification; + return notification != null ? notification.Value : o; + } + + /// + /// Gets an exception from an object that may be a . + /// + /// The object. + /// The value. + /// + /// If is a then returns the binding + /// notification's . If not, returns the object unchanged. + /// + public static object ExtractError(object o) + { + var notification = o as BindingNotification; + return notification != null ? notification.Error : o; + } + + /// + /// Compares an object to an instance of for equality. + /// + /// The object to compare. + /// true if the two instances are equal; otherwise false. + public override bool Equals(object obj) + { + return Equals(obj as BindingNotification); + } + + /// + /// Compares a value to an instance of for equality. + /// + /// The value to compare. + /// true if the two instances are equal; otherwise false. + public bool Equals(BindingNotification other) + { + return this == other; + } + + /// + /// Gets the hash code for this instance of . + /// + /// A hash code. + public override int GetHashCode() + { + return base.GetHashCode(); + } + + /// + /// Adds an error to the . + /// + /// The error to add. + /// The error type. + public void AddError(Exception e, BindingErrorType type) + { + Contract.Requires(e != null); + Contract.Requires(type != BindingErrorType.None); + + Error = Error != null ? new AggregateException(Error, e) : e; + + if (type == BindingErrorType.Error || ErrorType == BindingErrorType.Error) + { + ErrorType = BindingErrorType.Error; + } + } + + /// + /// Removes the and makes return null. + /// + public void ClearValue() + { + _value = null; + } + + /// + /// Sets the . + /// + public void SetValue(object value) + { + _value = new WeakReference(value ?? NullValue); + } + + /// + public override string ToString() + { + switch (ErrorType) + { + case BindingErrorType.None: + return $"{{Value: {Value}}}"; + default: + return HasValue ? + $"{{{ErrorType}: {Error}, Fallback: {Value}}}" : + $"{{{ErrorType}: {Error}}}"; + } + } + + private static bool ExceptionEquals(Exception a, Exception b) + { + return a?.GetType() == b?.GetType() && + a?.Message == b?.Message; + } + } +} diff --git a/src/Avalonia.Base/Data/BindingOperations.cs b/src/Avalonia.Base/Data/BindingOperations.cs index 9899eb633c..eb7c449bec 100644 --- a/src/Avalonia.Base/Data/BindingOperations.cs +++ b/src/Avalonia.Base/Data/BindingOperations.cs @@ -54,7 +54,10 @@ namespace Avalonia.Data if (source != null) { - return source.Take(1).Subscribe(x => target.SetValue(property, x, binding.Priority)); + return source + .Where(x => BindingNotification.ExtractValue(x) != AvaloniaProperty.UnsetValue) + .Take(1) + .Subscribe(x => target.SetValue(property, x, binding.Priority)); } else { diff --git a/src/Avalonia.Base/Data/IBinding.cs b/src/Avalonia.Base/Data/IBinding.cs index 88c20dd288..70447ad3eb 100644 --- a/src/Avalonia.Base/Data/IBinding.cs +++ b/src/Avalonia.Base/Data/IBinding.cs @@ -19,12 +19,14 @@ namespace Avalonia.Data /// order to locate named controls or resources. The parameter /// can be used to provice this context. /// + /// Whether data validation should be enabled. /// /// A or null if the binding could not be resolved. /// InstancedBinding Initiate( IAvaloniaObject target, AvaloniaProperty targetProperty, - object anchor = null); + object anchor = null, + bool enableDataValidation = false); } } diff --git a/src/Avalonia.Base/Data/IValidationStatus.cs b/src/Avalonia.Base/Data/IValidationStatus.cs deleted file mode 100644 index 30a2459af0..0000000000 --- a/src/Avalonia.Base/Data/IValidationStatus.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -namespace Avalonia.Data -{ - /// - /// Contains information on if the current object passed validation. - /// Subclasses of this class contain additional information depending on the method of validation checking. - /// - public interface IValidationStatus - { - /// - /// True when the data passes validation; otherwise, false. - /// - bool IsValid { get; } - } -} diff --git a/src/Avalonia.Base/Data/IndexerBinding.cs b/src/Avalonia.Base/Data/IndexerBinding.cs index b2d38920d0..729b21b0d9 100644 --- a/src/Avalonia.Base/Data/IndexerBinding.cs +++ b/src/Avalonia.Base/Data/IndexerBinding.cs @@ -21,7 +21,11 @@ namespace Avalonia.Data public AvaloniaProperty Property { get; } private BindingMode Mode { get; } - public InstancedBinding Initiate(IAvaloniaObject target, AvaloniaProperty targetProperty, object anchor = null) + public InstancedBinding Initiate( + IAvaloniaObject target, + AvaloniaProperty targetProperty, + object anchor = null, + bool enableDataValidation = false) { var mode = Mode == BindingMode.Default ? targetProperty.GetMetadata(target.GetType()).DefaultBindingMode : diff --git a/src/Avalonia.Base/Data/ObjectValidationStatus.cs b/src/Avalonia.Base/Data/ObjectValidationStatus.cs deleted file mode 100644 index 9ce3dcd897..0000000000 --- a/src/Avalonia.Base/Data/ObjectValidationStatus.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Avalonia.Data -{ - /// - /// An immutable struct that contains validation information for a that validates a single property. - /// - public struct ObjectValidationStatus : IValidationStatus - { - private Dictionary currentValidationStatus; - - public bool IsValid => currentValidationStatus?.Values.All(status => status.IsValid) ?? true; - - /// - /// Constructs the structure with the given validation information. - /// - /// The validation information - public ObjectValidationStatus(Dictionary validations) - :this() - { - currentValidationStatus = validations; - } - - /// - /// Creates a new status with the updated information. - /// - /// The updated status information. - /// The new validation status. - public ObjectValidationStatus UpdateValidationStatus(IValidationStatus status) - { - var newStatus = new Dictionary(currentValidationStatus ?? - new Dictionary()); - newStatus[status.GetType()] = status; - return new ObjectValidationStatus(newStatus); - } - - public IEnumerable StatusInformation => currentValidationStatus.Values; - } -} diff --git a/src/Avalonia.Base/DirectProperty.cs b/src/Avalonia.Base/DirectProperty.cs index cf325ed269..fad6cf983a 100644 --- a/src/Avalonia.Base/DirectProperty.cs +++ b/src/Avalonia.Base/DirectProperty.cs @@ -85,19 +85,26 @@ namespace Avalonia /// The value to use when the property is set to /// /// The default binding mode for the property. + /// + /// Whether the property is interested in data validation. + /// /// The property. public DirectProperty AddOwner( Func getter, Action setter = null, TValue unsetValue = default(TValue), - BindingMode defaultBindingMode = BindingMode.OneWay) + BindingMode defaultBindingMode = BindingMode.OneWay, + bool enableDataValidation = false) where TNewOwner : AvaloniaObject { var result = new DirectProperty( this, getter, setter, - new DirectPropertyMetadata(unsetValue, defaultBindingMode)); + new DirectPropertyMetadata( + unsetValue: unsetValue, + defaultBindingMode: defaultBindingMode, + enableDataValidation: enableDataValidation)); AvaloniaPropertyRegistry.Instance.Register(typeof(TNewOwner), result); return result; diff --git a/src/Avalonia.Base/DirectPropertyMetadata`1.cs b/src/Avalonia.Base/DirectPropertyMetadata`1.cs index 69dac6e8e2..d22801e35a 100644 --- a/src/Avalonia.Base/DirectPropertyMetadata`1.cs +++ b/src/Avalonia.Base/DirectPropertyMetadata`1.cs @@ -17,19 +17,35 @@ namespace Avalonia /// The value to use when the property is set to /// /// The default binding mode. + /// + /// Whether the property is interested in data validation. + /// public DirectPropertyMetadata( TValue unsetValue = default(TValue), - BindingMode defaultBindingMode = BindingMode.Default) + BindingMode defaultBindingMode = BindingMode.Default, + bool enableDataValidation = false) : base(defaultBindingMode) { UnsetValue = unsetValue; + EnableDataValidation = enableDataValidation; } /// - /// Gets the to use when the property is set to . + /// Gets the value to use when the property is set to . /// public TValue UnsetValue { get; private set; } + /// + /// Gets a value indicating whether the property is interested in data validation. + /// + /// + /// Data validation is validation performed at the target of a binding, for example in a + /// view model using the INotifyDataErrorInfo interface. Only certain properties on a + /// control (such as a TextBox's Text property) will be interested in recieving data + /// validation messages so this feature must be explicitly enabled by setting this flag. + /// + public bool EnableDataValidation { get; } + /// object IDirectPropertyMetadata.UnsetValue => UnsetValue; diff --git a/src/Avalonia.Base/IDirectPropertyMetadata.cs b/src/Avalonia.Base/IDirectPropertyMetadata.cs index e9b7603ffe..9dc014f0b8 100644 --- a/src/Avalonia.Base/IDirectPropertyMetadata.cs +++ b/src/Avalonia.Base/IDirectPropertyMetadata.cs @@ -12,5 +12,10 @@ namespace Avalonia /// Gets the to use when the property is set to . /// object UnsetValue { get; } + + /// + /// Gets a value indicating whether the property is interested in data validation. + /// + bool EnableDataValidation { get; } } } \ No newline at end of file diff --git a/src/Avalonia.Base/IPriorityValueOwner.cs b/src/Avalonia.Base/IPriorityValueOwner.cs index 743eba4453..57f98c0717 100644 --- a/src/Avalonia.Base/IPriorityValueOwner.cs +++ b/src/Avalonia.Base/IPriorityValueOwner.cs @@ -19,10 +19,11 @@ namespace Avalonia void Changed(PriorityValue sender, object oldValue, object newValue); /// - /// Called when the validation state of a changes. + /// Called when a is received by a + /// . /// /// The source of the change. - /// The validation status. - void DataValidationChanged(PriorityValue sender, IValidationStatus status); + /// The notification. + void BindingNotificationReceived(PriorityValue sender, BindingNotification notification); } } diff --git a/src/Avalonia.Base/PriorityBindingEntry.cs b/src/Avalonia.Base/PriorityBindingEntry.cs index 3368f45b82..580b593666 100644 --- a/src/Avalonia.Base/PriorityBindingEntry.cs +++ b/src/Avalonia.Base/PriorityBindingEntry.cs @@ -93,22 +93,24 @@ namespace Avalonia private void ValueChanged(object value) { - var bindingError = value as BindingError; + var notification = value as BindingNotification; - if (bindingError != null) + if (notification != null) { - _owner.Error(this, bindingError); + if (notification.HasValue) + { + Value = notification.Value; + _owner.Changed(this); + } + + if (notification.ErrorType != BindingErrorType.None) + { + _owner.Error(this, notification); + } } - - var validationStatus = value as IValidationStatus; - - if (validationStatus != null) - { - _owner.Validation(this, validationStatus); - } - else if (bindingError == null || bindingError.UseFallbackValue) + else { - Value = bindingError == null ? value : bindingError.FallbackValue; + Value = value; _owner.Changed(this); } } diff --git a/src/Avalonia.Base/PriorityLevel.cs b/src/Avalonia.Base/PriorityLevel.cs index c0460a775b..122a6df821 100644 --- a/src/Avalonia.Base/PriorityLevel.cs +++ b/src/Avalonia.Base/PriorityLevel.cs @@ -159,22 +159,11 @@ namespace Avalonia /// /// The entry that completed. /// The error. - public void Error(PriorityBindingEntry entry, BindingError error) + public void Error(PriorityBindingEntry entry, BindingNotification error) { _owner.LevelError(this, error); } - /// - /// Invoked when an entry in reports validation status. - /// - /// The entry that completed. - /// The validation status. - public void Validation(PriorityBindingEntry entry, IValidationStatus validationStatus) - { - _owner.LevelValidation(this, validationStatus); - } - - /// /// Activates the first binding that has a value. /// diff --git a/src/Avalonia.Base/PriorityValue.cs b/src/Avalonia.Base/PriorityValue.cs index 20434bc97d..a7eb4465b3 100644 --- a/src/Avalonia.Base/PriorityValue.cs +++ b/src/Avalonia.Base/PriorityValue.cs @@ -77,7 +77,6 @@ namespace Avalonia /// /// The binding. /// The binding priority. - /// Validation settings for the binding. /// /// A disposable that will remove the binding. /// @@ -179,31 +178,21 @@ namespace Avalonia } } - /// - /// Called whenever a priority level validation state changes. - /// - /// The priority level of the changed entry. - /// The validation status. - public void LevelValidation(PriorityLevel priorityLevel, IValidationStatus validationStatus) - { - _owner.DataValidationChanged(this, validationStatus); - } - /// /// Called when a priority level encounters an error. /// /// The priority level of the changed entry. /// The binding error. - public void LevelError(PriorityLevel level, BindingError error) + public void LevelError(PriorityLevel level, BindingNotification error) { Logger.Log( LogEventLevel.Error, LogArea.Binding, _owner, - "Error binding to {Target}.{Property}: {Message}", + "Error in binding to {Target}.{Property}: {Message}", _owner, Property, - error.Exception.Message); + error.Error.Message); } /// @@ -248,8 +237,14 @@ namespace Avalonia /// The priority level that the value came from. private void UpdateValue(object value, int priority) { + var notification = value as BindingNotification; object castValue; + if (notification != null) + { + value = (notification.HasValue) ? notification.Value : null; + } + if (TypeUtilities.TryCast(_valueType, value, out castValue)) { var old = _value; @@ -261,7 +256,21 @@ namespace Avalonia ValuePriority = priority; _value = castValue; - _owner?.Changed(this, old, _value); + + if (notification?.HasValue == true) + { + notification.SetValue(castValue); + } + + if (notification == null || notification.HasValue) + { + _owner?.Changed(this, old, _value); + } + + if (notification != null) + { + _owner?.BindingNotificationReceived(this, notification); + } } else { diff --git a/src/Avalonia.Base/PropertyMetadata.cs b/src/Avalonia.Base/PropertyMetadata.cs index 4cbb99f7e4..395aad53e4 100644 --- a/src/Avalonia.Base/PropertyMetadata.cs +++ b/src/Avalonia.Base/PropertyMetadata.cs @@ -17,7 +17,8 @@ namespace Avalonia /// Initializes a new instance of the class. /// /// The default binding mode. - public PropertyMetadata(BindingMode defaultBindingMode = BindingMode.Default) + public PropertyMetadata( + BindingMode defaultBindingMode = BindingMode.Default) { _defaultBindingMode = defaultBindingMode; } diff --git a/src/Avalonia.Base/Utilities/ExceptionUtilities.cs b/src/Avalonia.Base/Utilities/ExceptionUtilities.cs new file mode 100644 index 0000000000..fa8c5be788 --- /dev/null +++ b/src/Avalonia.Base/Utilities/ExceptionUtilities.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Avalonia.Utilities +{ + internal static class ExceptionUtilities + { + public static string GetMessage(Exception e) + { + var aggregate = e as AggregateException; + + if (aggregate != null) + { + return string.Join(" | ", aggregate.InnerExceptions.Select(x => x.Message)); + } + + return e.Message; + } + } +} diff --git a/src/Avalonia.Base/Utilities/TypeUtilities.cs b/src/Avalonia.Base/Utilities/TypeUtilities.cs index 2d4c911933..7295bfa7ab 100644 --- a/src/Avalonia.Base/Utilities/TypeUtilities.cs +++ b/src/Avalonia.Base/Utilities/TypeUtilities.cs @@ -27,6 +27,21 @@ namespace Avalonia.Utilities { typeof(short), new List { typeof(byte) } } }; + private static readonly Type[] NumericTypes = new[] + { + typeof(Byte), + typeof(Decimal), + typeof(Double), + typeof(Int16), + typeof(Int32), + typeof(Int64), + typeof(SByte), + typeof(Single), + typeof(UInt16), + typeof(UInt32), + typeof(UInt64), + }; + /// /// Returns a value indicating whether null can be assigned to the specified type. /// @@ -208,5 +223,31 @@ namespace Avalonia.Utilities return null; } } + + /// + /// Determines if a type is numeric. Nullable numeric types are considered numeric. + /// + /// + /// True if the type is numberic; otherwise false. + /// + /// + /// Boolean is not considered numeric. + /// + public static bool IsNumeric(Type type) + { + if (type == null) + { + return false; + } + + if (type.GetTypeInfo().IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return IsNumeric(Nullable.GetUnderlyingType(type)); + } + else + { + return NumericTypes.Contains(type); + } + } } } diff --git a/src/Avalonia.Base/Utilities/WeakObservable.cs b/src/Avalonia.Base/Utilities/WeakObservable.cs new file mode 100644 index 0000000000..c261cc0520 --- /dev/null +++ b/src/Avalonia.Base/Utilities/WeakObservable.cs @@ -0,0 +1,54 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Reactive; +using System.Reactive.Linq; + +namespace Avalonia.Utilities +{ + /// + /// Provides extension methods for working with weak event handlers. + /// + public static class WeakObservable + { + /// + /// Converts a .NET event conforming to the standard .NET event pattern into an observable + /// sequence, subscribing weakly. + /// + /// The type of the event args. + /// Object instance that exposes the event to convert. + /// Name of the event to convert. + /// + public static IObservable> FromEventPattern( + object target, + string eventName) + where TEventArgs : EventArgs + { + Contract.Requires(target != null); + Contract.Requires(eventName != null); + + return Observable.Create>(observer => + { + var handler = new Handler(observer); + WeakSubscriptionManager.Subscribe(target, eventName, handler); + return () => WeakSubscriptionManager.Unsubscribe(target, eventName, handler); + }).Publish().RefCount(); + } + + private class Handler : IWeakSubscriber where TEventArgs : EventArgs + { + private IObserver> _observer; + + public Handler(IObserver> observer) + { + _observer = observer; + } + + public void OnEvent(object sender, TEventArgs e) + { + _observer.OnNext(new EventPattern(sender, e)); + } + } + } +} diff --git a/src/Avalonia.Controls/Canvas.cs b/src/Avalonia.Controls/Canvas.cs index 74eebea4bc..8b433c2b67 100644 --- a/src/Avalonia.Controls/Canvas.cs +++ b/src/Avalonia.Controls/Canvas.cs @@ -47,6 +47,7 @@ namespace Avalonia.Controls /// static Canvas() { + ClipToBoundsProperty.OverrideDefaultValue(false); AffectsCanvasArrange(LeftProperty, TopProperty, RightProperty, BottomProperty); } diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 944f6b82ac..5cd2ddfc35 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -108,7 +108,6 @@ namespace Avalonia.Controls PseudoClass(IsEnabledCoreProperty, x => !x, ":disabled"); PseudoClass(IsFocusedProperty, ":focus"); PseudoClass(IsPointerOverProperty, ":pointerover"); - PseudoClass(ValidationStatusProperty, status => !status.IsValid, ":invalid"); } /// @@ -400,13 +399,6 @@ namespace Avalonia.Controls /// protected IPseudoClasses PseudoClasses => Classes; - /// - protected override void DataValidationChanged(AvaloniaProperty property, IValidationStatus status) - { - base.DataValidationChanged(property, status); - ValidationStatus.UpdateValidationStatus(status); - } - /// /// Sets the control's logical parent. /// @@ -729,7 +721,6 @@ namespace Avalonia.Controls _isAttachedToLogicalTree = false; _styleDetach.OnNext(this); - this.TemplatedParent = null; OnDetachedFromLogicalTree(e); foreach (var child in LogicalChildren.OfType()) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 8c4f58148f..8f92cc43b7 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -285,17 +285,6 @@ namespace Avalonia.Controls LogicalChildren.RemoveAll(toRemove); } - /// - protected override void OnTemplateChanged(AvaloniaPropertyChangedEventArgs e) - { - base.OnTemplateChanged(e); - - if (e.NewValue == null) - { - ItemContainerGenerator?.Clear(); - } - } - /// /// Caled when the property changes. /// diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index 16c66c7cfc..f67bbef5e1 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs @@ -130,7 +130,7 @@ namespace Avalonia.Controls.Primitives control.ApplyTemplate(); - if (!(control is IPresenter && control.TemplatedParent == templatedParent)) + if (!(control is IPresenter) && control.TemplatedParent == templatedParent) { foreach (IControl child in control.GetVisualChildren()) { diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index a1d913d40a..5806dd5bb3 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -35,6 +35,14 @@ namespace Avalonia.Controls o => o.CaretIndex, (o, v) => o.CaretIndex = v); + public static readonly DirectProperty> DataValidationErrorsProperty = + AvaloniaProperty.RegisterDirect>( + nameof(DataValidationErrors), + o => o.DataValidationErrors); + + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + public static readonly DirectProperty SelectionStartProperty = AvaloniaProperty.RegisterDirect( nameof(SelectionStart), @@ -51,7 +59,8 @@ namespace Avalonia.Controls TextBlock.TextProperty.AddOwner( o => o.Text, (o, v) => o.Text = v, - defaultBindingMode: BindingMode.TwoWay); + defaultBindingMode: BindingMode.TwoWay, + enableDataValidation: true); public static readonly StyledProperty TextAlignmentProperty = TextBlock.TextAlignmentProperty.AddOwner(); @@ -65,9 +74,6 @@ namespace Avalonia.Controls public static readonly StyledProperty UseFloatingWatermarkProperty = AvaloniaProperty.Register("UseFloatingWatermark"); - public static readonly StyledProperty IsReadOnlyProperty = - AvaloniaProperty.Register(nameof(IsReadOnly)); - struct UndoRedoState : IEquatable { public string Text { get; } @@ -89,6 +95,8 @@ namespace Avalonia.Controls private bool _canScrollHorizontally; private TextPresenter _presenter; private UndoRedoHelper _undoRedoHelper; + private bool _ignoreTextChanges; + private IEnumerable _dataValidationErrors; static TextBox() { @@ -145,6 +153,18 @@ namespace Avalonia.Controls } } + public IEnumerable DataValidationErrors + { + get { return _dataValidationErrors; } + private set { SetAndRaise(DataValidationErrorsProperty, ref _dataValidationErrors, value); } + } + + public bool IsReadOnly + { + get { return GetValue(IsReadOnlyProperty); } + set { SetValue(IsReadOnlyProperty, value); } + } + public int SelectionStart { get @@ -177,7 +197,13 @@ namespace Avalonia.Controls public string Text { get { return _text; } - set { SetAndRaise(TextProperty, ref _text, value); } + set + { + if (!_ignoreTextChanges) + { + SetAndRaise(TextProperty, ref _text, value); + } + } } public TextAlignment TextAlignment @@ -198,12 +224,6 @@ namespace Avalonia.Controls set { SetValue(UseFloatingWatermarkProperty, value); } } - public bool IsReadOnly - { - get { return GetValue(IsReadOnlyProperty); } - set { SetValue(IsReadOnlyProperty, value); } - } - public TextWrapping TextWrapping { get { return GetValue(TextWrappingProperty); } @@ -235,14 +255,6 @@ namespace Avalonia.Controls HandleTextInput(e.Text); } - protected override void DataValidationChanged(AvaloniaProperty property, IValidationStatus status) - { - if (property == TextProperty) - { - UpdateValidationState(status); - } - } - private void HandleTextInput(string input) { if (!IsReadOnly) @@ -254,7 +266,7 @@ namespace Avalonia.Controls DeleteSelection(); caretIndex = CaretIndex; text = Text ?? string.Empty; - Text = text.Substring(0, caretIndex) + input + text.Substring(caretIndex); + SetTextInternal(text.Substring(0, caretIndex) + input + text.Substring(caretIndex)); CaretIndex += input.Length; SelectionStart = SelectionEnd = CaretIndex; _undoRedoHelper.DiscardRedo(); @@ -367,7 +379,8 @@ namespace Avalonia.Controls if (!DeleteSelection() && CaretIndex > 0) { - CaretIndex -= DeleteCharacter(CaretIndex - 1); + SetTextInternal(text.Substring(0, caretIndex - 1) + text.Substring(caretIndex)); + --CaretIndex; } break; @@ -380,7 +393,7 @@ namespace Avalonia.Controls if (!DeleteSelection() && caretIndex < text.Length) { - DeleteCharacter(CaretIndex); + SetTextInternal(text.Substring(0, caretIndex) + text.Substring(caretIndex + 1)); } break; @@ -478,6 +491,35 @@ namespace Avalonia.Controls } } + protected override void UpdateDataValidation(AvaloniaProperty property, BindingNotification status) + { + if (property == TextProperty) + { + var classes = (IPseudoClasses)Classes; + DataValidationErrors = UnpackException(status.Error); + classes.Set(":error", DataValidationErrors != null); + } + } + + private static IEnumerable UnpackException(Exception exception) + { + if (exception != null) + { + var aggregate = exception as AggregateException; + var exceptions = aggregate == null ? + (IEnumerable)new[] { exception } : + aggregate.InnerExceptions; + var filtered = exceptions.Where(x => !(x is BindingChainNullException)).ToList(); + + if (filtered.Count > 0) + { + return filtered; + } + } + + return null; + } + private int CoerceCaretIndex(int value) { var text = Text; @@ -663,7 +705,7 @@ namespace Avalonia.Controls var start = Math.Min(selectionStart, selectionEnd); var end = Math.Max(selectionStart, selectionEnd); var text = Text; - Text = text.Substring(0, start) + text.Substring(end); + SetTextInternal(text.Substring(0, start) + text.Substring(end)); SelectionStart = SelectionEnd = CaretIndex = start; return true; } @@ -710,6 +752,19 @@ namespace Avalonia.Controls return i; } + private void SetTextInternal(string value) + { + try + { + _ignoreTextChanges = true; + SetAndRaise(TextProperty, ref _text, value); + } + finally + { + _ignoreTextChanges = false; + } + } + private void SetSelectionForControlBackspace(InputModifiers modifiers) { SelectionStart = CaretIndex; diff --git a/src/Avalonia.Controls/ToolTip.cs b/src/Avalonia.Controls/ToolTip.cs index 060d559d1a..cff3dab150 100644 --- a/src/Avalonia.Controls/ToolTip.cs +++ b/src/Avalonia.Controls/ToolTip.cs @@ -105,20 +105,17 @@ namespace Avalonia.Controls { if (control != null && control.IsVisible && control.GetVisualRoot() != null) { - if (s_popup == null) + if (s_popup != null) { - s_popup = new PopupRoot - { - Content = new ToolTip(), - }; - - ((ISetLogicalParent)s_popup).SetParent(control); + throw new AvaloniaInternalException("Previous ToolTip not disposed."); } var cp = MouseDevice.Instance?.GetPosition(control); var position = control.PointToScreen(cp ?? new Point(0, 0)) + new Vector(0, 22); - ((ToolTip)s_popup.Content).Content = GetTip(control); + s_popup = new PopupRoot(); + ((ISetLogicalParent)s_popup).SetParent(control); + s_popup.Content = new ToolTip { Content = GetTip(control) }; s_popup.Position = position; s_popup.Show(); @@ -148,9 +145,15 @@ namespace Avalonia.Controls if (control == s_current) { - if (s_popup != null && s_popup.IsVisible) + if (s_popup != null) { - s_popup.Hide(); + // Clear the ToolTip's Content in case it has control content: this will + // reset its visual parent allowing it to be used again. + ((ToolTip)s_popup.Content).Content = null; + + // Dispose of the popup. + s_popup.Dispose(); + s_popup = null; } s_show.OnNext(null); diff --git a/src/Avalonia.DotNetFrameworkRuntime/Avalonia.DotNetFrameworkRuntime.v2.ncrunchproject b/src/Avalonia.DotNetFrameworkRuntime/Avalonia.DotNetFrameworkRuntime.v2.ncrunchproject new file mode 100644 index 0000000000..30815b1937 --- /dev/null +++ b/src/Avalonia.DotNetFrameworkRuntime/Avalonia.DotNetFrameworkRuntime.v2.ncrunchproject @@ -0,0 +1,26 @@ + + true + 1000 + false + false + false + true + false + false + false + false + false + true + true + false + true + true + true + 60000 + + + + AutoDetect + STA + x86 + \ No newline at end of file diff --git a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml index 2d1e833199..38786f54d3 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml @@ -18,6 +18,8 @@ #99119EDA #66119EDA #33119EDA + Red + #10ff0000 2 0.5 diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index cc8b6722ee..07a3a6f429 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -1,4 +1,7 @@ + + + @@ -28,7 +31,6 @@ - diff --git a/src/Avalonia.Themes.Default/TextBox.xaml b/src/Avalonia.Themes.Default/TextBox.xaml index c68fab958e..8a5a41845b 100644 --- a/src/Avalonia.Themes.Default/TextBox.xaml +++ b/src/Avalonia.Themes.Default/TextBox.xaml @@ -27,10 +27,19 @@ - - + + + + + + + + + + + - + + @@ -57,7 +67,17 @@ - + + + \ No newline at end of file diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index ec60695374..086257f24c 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs @@ -27,10 +27,12 @@ namespace Avalonia.Markup.Xaml.Data /// Initializes a new instance of the class. /// /// The binding path. - public Binding(string path) + /// The binding mode. + public Binding(string path, BindingMode mode = BindingMode.Default) : this() { Path = path; + Mode = mode; } /// @@ -78,21 +80,18 @@ namespace Avalonia.Markup.Xaml.Data /// public object Source { get; set; } - /// - /// Gets or sets a value indicating whether the property should be validated. - /// - public bool EnableValidation { get; set; } - /// public InstancedBinding Initiate( IAvaloniaObject target, AvaloniaProperty targetProperty, - object anchor = null) + object anchor = null, + bool enableDataValidation = false) { Contract.Requires(target != null); var pathInfo = ParsePath(Path); ValidateState(pathInfo); + enableDataValidation = enableDataValidation && Priority == BindingPriority.LocalValue; ExpressionObserver observer; @@ -105,7 +104,7 @@ namespace Avalonia.Markup.Xaml.Data } else if (Source != null) { - observer = CreateSourceObserver(Source, pathInfo.Path); + observer = CreateSourceObserver(Source, pathInfo.Path, enableDataValidation); } else if (RelativeSource == null || RelativeSource.Mode == RelativeSourceMode.DataContext) { @@ -113,7 +112,8 @@ namespace Avalonia.Markup.Xaml.Data target, pathInfo.Path, targetProperty == Control.DataContextProperty, - anchor); + anchor, + enableDataValidation); } else if (RelativeSource.Mode == RelativeSourceMode.TemplatedParent) { @@ -135,7 +135,7 @@ namespace Avalonia.Markup.Xaml.Data fallback = null; } - var subject = new ExpressionSubject( + var subject = new BindingExpression( observer, targetProperty?.PropertyType ?? typeof(object), fallback, @@ -197,7 +197,8 @@ namespace Avalonia.Markup.Xaml.Data IAvaloniaObject target, string path, bool targetIsDataContext, - object anchor) + object anchor, + bool enableDataValidation) { Contract.Requires(target != null); @@ -220,19 +221,16 @@ namespace Avalonia.Markup.Xaml.Data () => target.GetValue(Control.DataContextProperty), path, update, - EnableValidation); + enableDataValidation); return result; } else { return new ExpressionObserver( - target.GetObservable(Visual.VisualParentProperty) - .OfType() - .Select(x => x.GetObservable(Control.DataContextProperty)) - .Switch(), + GetParentDataContext(target), path, - EnableValidation); + enableDataValidation); } } @@ -240,18 +238,23 @@ namespace Avalonia.Markup.Xaml.Data { Contract.Requires(target != null); + var description = $"#{elementName}.{path}"; var result = new ExpressionObserver( ControlLocator.Track(target, elementName), path, - EnableValidation); + false, + description); return result; } - private ExpressionObserver CreateSourceObserver(object source, string path) + private ExpressionObserver CreateSourceObserver( + object source, + string path, + bool enabledDataValidation) { Contract.Requires(source != null); - return new ExpressionObserver(source, path, EnableValidation); + return new ExpressionObserver(source, path, enabledDataValidation); } private ExpressionObserver CreateTemplatedParentObserver( @@ -272,6 +275,22 @@ namespace Avalonia.Markup.Xaml.Data return result; } + private IObservable GetParentDataContext(IAvaloniaObject target) + { + // The DataContext is based on the visual parent and not the logical parent: this may + // seem unintuitive considering the fact that property inheritance works on the logical + // tree, but consider a ContentControl with a ContentPresenter. The ContentControl's + // Content property is bound to a value which becomes the ContentPresenter's + // DataContext - it is from this that the child hosted by the ContentPresenter needs to + // inherit its DataContext. + return target.GetObservable(Visual.VisualParentProperty) + .Select(x => + { + return (x as IAvaloniaObject)?.GetObservable(Control.DataContextProperty) ?? + Observable.Return((object)null); + }).Switch(); + } + private class PathInfo { public string Path { get; set; } diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/MultiBinding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/MultiBinding.cs index 91a502a44f..69190be220 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/MultiBinding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/MultiBinding.cs @@ -53,7 +53,8 @@ namespace Avalonia.Markup.Xaml.Data public InstancedBinding Initiate( IAvaloniaObject target, AvaloniaProperty targetProperty, - object anchor = null) + object anchor = null, + bool enableDataValidation = false) { if (Converter == null) { diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/StyleResourceBinding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/StyleResourceBinding.cs index 442f9199e8..c538c0768e 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/StyleResourceBinding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/StyleResourceBinding.cs @@ -37,7 +37,8 @@ namespace Avalonia.Markup.Xaml.Data public InstancedBinding Initiate( IAvaloniaObject target, AvaloniaProperty targetProperty, - object anchor = null) + object anchor = null, + bool enableDataValidation = false) { if (Name == "Red") { diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs index 70d3f7d161..b131f07fd4 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs @@ -29,7 +29,6 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions Mode = Mode, Path = Path, Priority = Priority, - EnableValidation = EnableValidation, }; } @@ -41,6 +40,5 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions public string Path { get; set; } public BindingPriority Priority { get; set; } = BindingPriority.LocalValue; public object Source { get; set; } - public bool EnableValidation { get; set; } } } \ No newline at end of file diff --git a/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs b/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs index be4287605c..5e412633d2 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs @@ -5,14 +5,13 @@ using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Markup.Data; using System; +using System.Reactive.Linq; namespace Avalonia.Markup.Xaml.Templates { public class MemberSelector : IMemberSelector { - private ExpressionNode _expressionNode; private string _memberName; - private ExpressionNode _memberValueNode; public string MemberName { @@ -22,8 +21,6 @@ namespace Avalonia.Markup.Xaml.Templates if (_memberName != value) { _memberName = value; - _expressionNode = null; - _memberValueNode = null; } } } @@ -35,34 +32,11 @@ namespace Avalonia.Markup.Xaml.Templates return o; } - if (_expressionNode == null) - { - _expressionNode = ExpressionNodeBuilder.Build(MemberName); - - _memberValueNode = _expressionNode; - - while (_memberValueNode.Next != null) - { - _memberValueNode = _memberValueNode.Next; - } - } - - _expressionNode.Target = new WeakReference(o); - - object result = _memberValueNode.CurrentValue.Target; - - _expressionNode.Target = null; - - if (result == AvaloniaProperty.UnsetValue) - { - return null; - } - else if (result is BindingError) - { - return null; - } + var expression = new ExpressionObserver(o, MemberName); + object result = AvaloniaProperty.UnsetValue; - return result; + expression.Subscribe(x => result = x); + return (result == AvaloniaProperty.UnsetValue || result is BindingNotification) ? null : result; } } } \ No newline at end of file diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 49a5a4a681..738f381f4e 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -41,14 +41,17 @@ Properties\SharedAssemblyInfo.cs + + - + + - + @@ -59,8 +62,12 @@ + + + + - + diff --git a/src/Markup/Avalonia.Markup/Data/BindingExpression.cs b/src/Markup/Avalonia.Markup/Data/BindingExpression.cs new file mode 100644 index 0000000000..0f4c091bff --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/BindingExpression.cs @@ -0,0 +1,305 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Globalization; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Avalonia.Data; +using Avalonia.Logging; +using Avalonia.Utilities; + +namespace Avalonia.Markup.Data +{ + /// + /// Binds to an expression on an object using a type value converter to convert the values + /// that are send and received. + /// + public class BindingExpression : ISubject, IDescription + { + private readonly ExpressionObserver _inner; + private readonly Type _targetType; + private readonly object _fallbackValue; + private readonly BindingPriority _priority; + private readonly Subject _errors = new Subject(); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The type to convert the value to. + public BindingExpression(ExpressionObserver inner, Type targetType) + : this(inner, targetType, DefaultValueConverter.Instance) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The type to convert the value to. + /// The value converter to use. + /// + /// A parameter to pass to . + /// + /// The binding priority. + public BindingExpression( + ExpressionObserver inner, + Type targetType, + IValueConverter converter, + object converterParameter = null, + BindingPriority priority = BindingPriority.LocalValue) + : this(inner, targetType, AvaloniaProperty.UnsetValue, converter, converterParameter, priority) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The type to convert the value to. + /// + /// The value to use when the binding is unable to produce a value. + /// + /// The value converter to use. + /// + /// A parameter to pass to . + /// + /// The binding priority. + public BindingExpression( + ExpressionObserver inner, + Type targetType, + object fallbackValue, + IValueConverter converter, + object converterParameter = null, + BindingPriority priority = BindingPriority.LocalValue) + { + Contract.Requires(inner != null); + Contract.Requires(targetType != null); + Contract.Requires(converter != null); + + _inner = inner; + _targetType = targetType; + Converter = converter; + ConverterParameter = converterParameter; + _fallbackValue = fallbackValue; + _priority = priority; + } + + /// + /// Gets the converter to use on the expression. + /// + public IValueConverter Converter { get; } + + /// + /// Gets a parameter to pass to . + /// + public object ConverterParameter { get; } + + /// + string IDescription.Description => _inner.Expression; + + /// + public void OnCompleted() + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnNext(object value) + { + using (_inner.Subscribe(_ => { })) + { + var type = _inner.ResultType; + + if (type != null) + { + var converted = Converter.ConvertBack( + value, + type, + ConverterParameter, + CultureInfo.CurrentUICulture); + + if (converted == AvaloniaProperty.UnsetValue) + { + converted = TypeUtilities.Default(type); + _inner.SetValue(converted, _priority); + } + else if (converted is BindingNotification) + { + var notification = converted as BindingNotification; + + if (notification.ErrorType == BindingErrorType.None) + { + throw new AvaloniaInternalException( + "IValueConverter should not return non-errored BindingNotification."); + } + + _errors.OnNext(notification); + + if (_fallbackValue != AvaloniaProperty.UnsetValue) + { + if (TypeUtilities.TryConvert( + type, + _fallbackValue, + CultureInfo.InvariantCulture, + out converted)) + { + _inner.SetValue(converted, _priority); + } + else + { + Logger.Error( + LogArea.Binding, + this, + "Could not convert FallbackValue {FallbackValue} to {Type}", + _fallbackValue, + type); + } + } + } + else + { + _inner.SetValue(converted, _priority); + } + } + } + } + + /// + public IDisposable Subscribe(IObserver observer) + { + return _inner.Select(ConvertValue).Merge(_errors).Subscribe(observer); + } + + private object ConvertValue(object value) + { + var notification = value as BindingNotification; + + if (notification == null) + { + var converted = Converter.Convert( + value, + _targetType, + ConverterParameter, + CultureInfo.CurrentUICulture); + + notification = converted as BindingNotification; + + if (notification?.ErrorType == BindingErrorType.None) + { + converted = notification.Value; + } + + if (_fallbackValue != AvaloniaProperty.UnsetValue && + (converted == AvaloniaProperty.UnsetValue || converted is BindingNotification)) + { + var fallback = ConvertFallback(); + converted = Merge(converted, fallback); + } + + return converted; + } + else + { + return ConvertValue(notification); + } + } + + private BindingNotification ConvertValue(BindingNotification notification) + { + if (notification.HasValue) + { + var converted = ConvertValue(notification.Value); + notification = Merge(notification, converted); + } + else if (_fallbackValue != AvaloniaProperty.UnsetValue) + { + var fallback = ConvertFallback(); + notification = Merge(notification, fallback); + } + + return notification; + } + + private BindingNotification ConvertFallback() + { + object converted; + + if (_fallbackValue == AvaloniaProperty.UnsetValue) + { + throw new AvaloniaInternalException("Cannot call ConvertFallback with no fallback value"); + } + + if (TypeUtilities.TryConvert( + _targetType, + _fallbackValue, + CultureInfo.InvariantCulture, + out converted)) + { + return new BindingNotification(converted); + } + else + { + return new BindingNotification( + new InvalidCastException( + $"Could not convert FallbackValue '{_fallbackValue}' to '{_targetType}'"), + BindingErrorType.Error); + } + } + + private static BindingNotification Merge(object a, BindingNotification b) + { + var an = a as BindingNotification; + + if (an != null) + { + Merge(an, b); + return an; + } + else + { + return b; + } + } + + private static BindingNotification Merge(BindingNotification a, object b) + { + var bn = b as BindingNotification; + + if (bn != null) + { + Merge(a, bn); + } + else + { + a.SetValue(b); + } + + return a; + } + + private static BindingNotification Merge(BindingNotification a, BindingNotification b) + { + if (b.HasValue) + { + a.SetValue(b.Value); + } + else + { + a.ClearValue(); + } + + if (b.Error != null) + { + a.AddError(b.Error, b.ErrorType); + } + + return a; + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs new file mode 100644 index 0000000000..02ecd817da --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs @@ -0,0 +1,18 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Reactive.Linq; + +namespace Avalonia.Markup.Data +{ + internal class EmptyExpressionNode : ExpressionNode + { + public override string Description => "."; + + protected override IObservable StartListeningCore(WeakReference reference) + { + return Observable.Return(reference.Target); + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs index 90a654d9e6..b0957c7187 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -2,124 +2,188 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.Reactive.Disposables; +using System.Reactive.Linq; using System.Reactive.Subjects; using Avalonia.Data; namespace Avalonia.Markup.Data { - internal abstract class ExpressionNode : IObservable + internal abstract class ExpressionNode : ISubject { protected static readonly WeakReference UnsetReference = new WeakReference(AvaloniaProperty.UnsetValue); - private WeakReference _target; - - private Subject _subject; - - private WeakReference _value = UnsetReference; + private WeakReference _target = UnsetReference; + private IDisposable _valueSubscription; + private IObserver _observer; + private IDisposable _valuePluginSubscription; + public abstract string Description { get; } public ExpressionNode Next { get; set; } public WeakReference Target { - get - { - return _target; - } + get { return _target; } set { - var newInstance = value?.Target; - var oldInstance = _target?.Target; + Contract.Requires(value != null); - if (!object.Equals(oldInstance, newInstance)) - { - if (oldInstance != null) - { - Unsubscribe(oldInstance); - } + var oldTarget = _target?.Target; + var newTarget = value.Target; + var running = _valueSubscription != null; + if (!ReferenceEquals(oldTarget, newTarget)) + { + _valueSubscription?.Dispose(); + _valueSubscription = null; + _valuePluginSubscription?.Dispose(); _target = value; - if (newInstance != null) + if (running) { - SubscribeAndUpdate(_target); - } - else - { - CurrentValue = UnsetReference; - } - - if (Next != null) - { - Next.Target = _value; + _valueSubscription = StartListening(); } } } } - public WeakReference CurrentValue + public IDisposable Subscribe(IObserver observer) { - get + if (_observer != null) { - return _value; + throw new AvaloniaInternalException("ExpressionNode can only be subscribed once."); } - set + _observer = observer; + var nextSubscription = Next?.Subscribe(this); + _valueSubscription = StartListening(); + + return Disposable.Create(() => { - _value = value; + _valueSubscription?.Dispose(); + _valueSubscription = null; + _valuePluginSubscription?.Dispose(); + _valuePluginSubscription = null; + nextSubscription?.Dispose(); + _observer = null; + }); + } - if (Next != null) - { - Next.Target = value; - } + void IObserver.OnCompleted() + { + throw new AvaloniaInternalException("ExpressionNode.OnCompleted should not be called."); + } - _subject?.OnNext(value.Target); - } + void IObserver.OnError(Exception error) + { + throw new AvaloniaInternalException("ExpressionNode.OnError should not be called."); } - public virtual bool SetValue(object value, BindingPriority priority) + void IObserver.OnNext(object value) { - return Next?.SetValue(value, priority) ?? false; + NextValueChanged(value); } - public virtual IDisposable Subscribe(IObserver observer) + protected virtual IObservable StartListeningCore(WeakReference reference) { - if (Next != null) + return Observable.Return(reference.Target); + } + + protected virtual void NextValueChanged(object value) + { + var bindingBroken = BindingNotification.ExtractError(value) as MarkupBindingChainNullException; + bindingBroken?.AddNode(Description); + _observer.OnNext(value); + } + + private IDisposable StartListening() + { + var target = _target.Target; + IObservable source; + + if (target == null) + { + source = Observable.Return(TargetNullNotification()); + } + else if (target == AvaloniaProperty.UnsetValue) { - return Next.Subscribe(observer); + source = Observable.Empty(); } else { - if (_subject == null) - { - _subject = new Subject(); - } - - observer.OnNext(CurrentValue.Target); - return _subject.Subscribe(observer); + source = StartListeningCore(_target); } - } - protected virtual void SubscribeAndUpdate(WeakReference reference) - { - CurrentValue = reference; + return source.Subscribe(TargetValueChanged); } - protected virtual void SendValidationStatus(IValidationStatus status) + private void TargetValueChanged(object value) { - //Even if elements only bound to sub-values, send validation changes along so they will be surfaced to the UI level. - if (_subject != null) + var notification = value as BindingNotification; + + if (notification == null) { - _subject.OnNext(status); + if (!HandleSpecialValue(value)) + { + if (Next != null) + { + Next.Target = new WeakReference(value); + } + else + { + _observer.OnNext(value); + } + } } else { - Next?.SendValidationStatus(status); + if (notification.Error != null) + { + _observer.OnNext(notification); + } + else if (notification.HasValue) + { + if (!HandleSpecialValue(notification.Value)) + { + if (Next != null) + { + Next.Target = new WeakReference(notification.Value); + } + else + { + _observer.OnNext(value); + } + } + } + } + } + + private bool HandleSpecialValue(object value) + { + if (_valuePluginSubscription == null) + { + var reference = new WeakReference(value); + + foreach (var plugin in ExpressionObserver.ValueHandlers) + { + if (plugin.Match(reference)) + { + _valuePluginSubscription = plugin.Start(reference)?.Subscribe(TargetValueChanged); + return true; + } + } } + + return false; } - protected virtual void Unsubscribe(object target) + private BindingNotification TargetNullNotification() { + return new BindingNotification( + new MarkupBindingChainNullException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue); } } } diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 0b5fbc6d7d..819949b7b9 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; +using System.Reactive.Subjects; using Avalonia.Data; using Avalonia.Markup.Data.Plugins; @@ -31,41 +32,57 @@ namespace Avalonia.Markup.Data /// An ordered collection of validation checker plugins that can be used to customize /// the validation of view model and model data. /// - public static readonly IList ValidationCheckers = - new List + public static readonly IList DataValidators = + new List { + new DataAnnotationsValidationPlugin(), new IndeiValidationPlugin(), + new ExceptionValidationPlugin(), }; - private readonly WeakReference _root; - private readonly Func _rootGetter; - private readonly IObservable _rootObservable; - private readonly IObservable _update; - private IDisposable _rootObserverSubscription; - private IDisposable _updateSubscription; - private int _count; + /// + /// An ordered collection of value handlers that can be used to customize the handling + /// of certain values. + /// + public static readonly IList ValueHandlers = + new List + { + new TaskValuePlugin(), + new ObservableValuePlugin(), + }; + + private static readonly object UninitializedValue = new object(); private readonly ExpressionNode _node; - private bool _enableValidation; + private readonly Subject _finished; + private readonly object _root; + private IObservable _result; /// /// Initializes a new instance of the class. /// /// The root object. /// The expression. - /// Whether property validation should be enabled. - public ExpressionObserver(object root, string expression, bool enableValidation = false) + /// Whether data validation should be enabled. + /// + /// A description of the expression. If null, will be used. + /// + public ExpressionObserver( + object root, + string expression, + bool enableDataValidation = false, + string description = null) { Contract.Requires(expression != null); - _root = new WeakReference(root); - _enableValidation = enableValidation; - - if (!string.IsNullOrWhiteSpace(expression)) + if (root == AvaloniaProperty.UnsetValue) { - _node = ExpressionNodeBuilder.Build(expression, enableValidation); + root = null; } Expression = expression; + Description = description ?? expression; + _node = Parse(expression, enableDataValidation); + _root = new WeakReference(root); } /// @@ -73,24 +90,24 @@ namespace Avalonia.Markup.Data /// /// An observable which provides the root object. /// The expression. - /// Whether property validation should be enabled. + /// Whether data validation should be enabled. + /// + /// A description of the expression. If null, will be used. + /// public ExpressionObserver( IObservable rootObservable, string expression, - bool enableValidation = false) + bool enableDataValidation = false, + string description = null) { Contract.Requires(rootObservable != null); Contract.Requires(expression != null); - _rootObservable = rootObservable; - _enableValidation = enableValidation; - - if (!string.IsNullOrWhiteSpace(expression)) - { - _node = ExpressionNodeBuilder.Build(expression, enableValidation); - } - Expression = expression; + Description = description ?? expression; + _node = Parse(expression, enableDataValidation); + _finished = new Subject(); + _root = rootObservable; } /// @@ -99,27 +116,28 @@ namespace Avalonia.Markup.Data /// A function which gets the root object. /// The expression. /// An observable which triggers a re-read of the getter. - /// Whether property validation should be enabled. + /// Whether data validation should be enabled. + /// + /// A description of the expression. If null, will be used. + /// public ExpressionObserver( Func rootGetter, string expression, IObservable update, - bool enableValidation = false) + bool enableDataValidation = false, + string description = null) { Contract.Requires(rootGetter != null); Contract.Requires(expression != null); Contract.Requires(update != null); - _rootGetter = rootGetter; - _update = update; - _enableValidation = enableValidation; - - if (!string.IsNullOrWhiteSpace(expression)) - { - _node = ExpressionNodeBuilder.Build(expression, enableValidation); - } - Expression = expression; + Description = description ?? expression; + _node = Parse(expression, enableDataValidation); + _finished = new Subject(); + + _node.Target = new WeakReference(rootGetter()); + _root = update.Select(x => rootGetter()); } /// @@ -129,27 +147,20 @@ namespace Avalonia.Markup.Data /// The binding priority to use. /// /// True if the value could be set; false if the expression does not evaluate to a - /// property. + /// property. Note that the must be subscribed to + /// before setting the target value can work, as setting the value requires the + /// expression to be evaluated. /// public bool SetValue(object value, BindingPriority priority = BindingPriority.LocalValue) { - IncrementCount(); - - if (_rootGetter != null && _node != null) - { - _node.Target = new WeakReference(_rootGetter()); - } - - try - { - return _node?.SetValue(value, priority) ?? false; - } - finally - { - DecrementCount(); - } + return (Leaf as PropertyAccessorNode)?.SetTargetValue(value, priority) ?? false; } + /// + /// Gets a description of the expression being observed. + /// + public string Description { get; } + /// /// Gets the expression being observed. /// @@ -159,41 +170,7 @@ namespace Avalonia.Markup.Data /// Gets the type of the expression result or null if the expression could not be /// evaluated. /// - public Type ResultType - { - get - { - IncrementCount(); - - try - { - if (_node != null) - { - return (Leaf as PropertyAccessorNode)?.PropertyType; - } - else if (_rootGetter != null) - { - return _rootGetter()?.GetType(); - } - else - { - return _root.Target?.GetType(); - } - } - finally - { - DecrementCount(); - } - } - } - - /// - string IDescription.Description => Expression; - - /// - /// Gets the root expression node. Used for testing. - /// - internal ExpressionNode Node => _node; + public Type ResultType => (Leaf as PropertyAccessorNode)?.PropertyType; /// /// Gets the leaf node. @@ -211,94 +188,88 @@ namespace Avalonia.Markup.Data /// protected override IDisposable SubscribeCore(IObserver observer) { - IncrementCount(); - - if (_node != null) + if (_result == null) { - IObservable source = _node; + var source = (IObservable)_node; - if (_rootObservable != null) - { - source = source.TakeUntil(_rootObservable.LastOrDefaultAsync()); - } - else if (_update != null) + if (_finished != null) { - source = source.TakeUntil(_update.LastOrDefaultAsync()); + source = source.TakeUntil(_finished); } - var subscription = source.Subscribe(observer); - - return Disposable.Create(() => - { - DecrementCount(); - subscription.Dispose(); - }); + _result = Observable.Using(StartRoot, _ => source) + .Select(ToWeakReference) + .Publish(UninitializedValue) + .RefCount() + .Where(x => x != UninitializedValue) + .Select(Translate); } - else if (_rootObservable != null) + + return _result.Subscribe(observer); + } + + private static ExpressionNode Parse(string expression, bool enableDataValidation) + { + if (!string.IsNullOrWhiteSpace(expression)) { - return _rootObservable.Subscribe(observer); + return ExpressionNodeBuilder.Build(expression, enableDataValidation); } else { - if (_update == null) - { - return Observable.Never() - .StartWith(_root.Target) - .Subscribe(observer); - } - else - { - return _update - .Select(_ => _rootGetter()) - .StartWith(_rootGetter()) - .Subscribe(observer); - } + return new EmptyExpressionNode(); } } - private void IncrementCount() + private static object ToWeakReference(object o) + { + return o is BindingNotification ? o : new WeakReference(o); + } + + private object Translate(object o) { - if (_count++ == 0 && _node != null) + var weak = o as WeakReference; + + if (weak != null) { - if (_rootGetter != null) - { - _node.Target = new WeakReference(_rootGetter()); + return weak.Target; + } + else + { + var broken = BindingNotification.ExtractError(o) as MarkupBindingChainNullException; - if (_update != null) + if (broken != null) + { + // We've received notification of a broken expression due to a null value + // somewhere in the chain. If this null value occurs at the first node then we + // ignore it, as its likely that e.g. the DataContext has not yet been set up. + if (broken.HasNodes) { - _updateSubscription = _update.Subscribe(x => - _node.Target = new WeakReference(_rootGetter())); + broken.Commit(Description); + } + else + { + o = AvaloniaProperty.UnsetValue; } } - else if (_rootObservable != null) - { - _rootObserverSubscription = _rootObservable.Subscribe(x => - _node.Target = new WeakReference(x)); - } - else - { - _node.Target = _root; - } + return o; } } - private void DecrementCount() + private IDisposable StartRoot() { - if (--_count == 0 && _node != null) - { - if (_rootObserverSubscription != null) - { - _rootObserverSubscription.Dispose(); - _rootObserverSubscription = null; - } + var observable = _root as IObservable; - if (_updateSubscription != null) - { - _updateSubscription.Dispose(); - _updateSubscription = null; - } - - _node.Target = null; + if (observable != null) + { + return observable.Subscribe( + x => _node.Target = new WeakReference(x != AvaloniaProperty.UnsetValue ? x : null), + _ => _finished.OnNext(Unit.Default), + () => _finished.OnNext(Unit.Default)); + } + else + { + _node.Target = (WeakReference)_root; + return Disposable.Empty; } } } diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs deleted file mode 100644 index 0a3be26c18..0000000000 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Globalization; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using Avalonia.Data; -using Avalonia.Logging; -using Avalonia.Utilities; - -namespace Avalonia.Markup.Data -{ - /// - /// Turns an into a subject that can be bound two-way with - /// a value converter. - /// - public class ExpressionSubject : ISubject, IDescription - { - private readonly ExpressionObserver _inner; - private readonly Type _targetType; - private readonly object _fallbackValue; - private readonly BindingPriority _priority; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The type to convert the value to. - public ExpressionSubject(ExpressionObserver inner, Type targetType) - : this(inner, targetType, DefaultValueConverter.Instance) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The type to convert the value to. - /// The value converter to use. - /// - /// A parameter to pass to . - /// - /// The binding priority. - public ExpressionSubject( - ExpressionObserver inner, - Type targetType, - IValueConverter converter, - object converterParameter = null, - BindingPriority priority = BindingPriority.LocalValue) - : this(inner, targetType, AvaloniaProperty.UnsetValue, converter, converterParameter, priority) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The type to convert the value to. - /// - /// The value to use when the binding is unable to produce a value. - /// - /// The value converter to use. - /// - /// A parameter to pass to . - /// - /// The binding priority. - public ExpressionSubject( - ExpressionObserver inner, - Type targetType, - object fallbackValue, - IValueConverter converter, - object converterParameter = null, - BindingPriority priority = BindingPriority.LocalValue) - { - Contract.Requires(inner != null); - Contract.Requires(targetType != null); - Contract.Requires(converter != null); - - _inner = inner; - _targetType = targetType; - Converter = converter; - ConverterParameter = converterParameter; - _fallbackValue = fallbackValue; - _priority = priority; - } - - /// - /// Gets the converter to use on the expression. - /// - public IValueConverter Converter { get; } - - /// - /// Gets a parameter to pass to . - /// - public object ConverterParameter { get; } - - /// - string IDescription.Description => _inner.Expression; - - /// - public void OnCompleted() - { - } - - /// - public void OnError(Exception error) - { - } - - /// - public void OnNext(object value) - { - var type = _inner.ResultType; - - if (type != null) - { - var converted = Converter.ConvertBack( - value, - type, - ConverterParameter, - CultureInfo.CurrentUICulture); - - if (converted == AvaloniaProperty.UnsetValue) - { - converted = TypeUtilities.Default(type); - _inner.SetValue(converted, _priority); - } - else if (converted is BindingError) - { - var error = converted as BindingError; - - Logger.Error( - LogArea.Binding, - this, - "Error binding to {Expression}: {Message}", - _inner.Expression, - error.Exception.Message); - - if (_fallbackValue != AvaloniaProperty.UnsetValue) - { - if (TypeUtilities.TryConvert( - type, - _fallbackValue, - CultureInfo.InvariantCulture, - out converted)) - { - _inner.SetValue(converted, _priority); - } - else - { - Logger.Error( - LogArea.Binding, - this, - "Could not convert FallbackValue {FallbackValue} to {Type}", - _fallbackValue, - type); - } - } - } - else - { - _inner.SetValue(converted, _priority); - } - } - } - - /// - public IDisposable Subscribe(IObserver observer) - { - return _inner.Select(ConvertValue).Subscribe(observer); - } - - private object ConvertValue(object value) - { - var converted = - value as BindingError ?? - value as IValidationStatus ?? - Converter.Convert( - value, - _targetType, - ConverterParameter, - CultureInfo.CurrentUICulture); - - if (_fallbackValue != AvaloniaProperty.UnsetValue && - (converted == AvaloniaProperty.UnsetValue || - converted is BindingError)) - { - var error = converted as BindingError; - - if (TypeUtilities.TryConvert( - _targetType, - _fallbackValue, - CultureInfo.InvariantCulture, - out converted)) - { - if (error != null) - { - converted = new BindingError(error.Exception, converted); - } - } - else - { - converted = new BindingError( - new InvalidCastException( - $"Could not convert FallbackValue '{_fallbackValue}' to '{_targetType}'")); - } - } - - return converted; - } - } -} diff --git a/src/Markup/Avalonia.Markup/Data/IndexerNode.cs b/src/Markup/Avalonia.Markup/Data/IndexerNode.cs index 8849e7edbc..0a6b93bad1 100644 --- a/src/Markup/Avalonia.Markup/Data/IndexerNode.cs +++ b/src/Markup/Avalonia.Markup/Data/IndexerNode.cs @@ -10,130 +10,49 @@ using System.ComponentModel; using System.Globalization; using System.Linq; using System.Reflection; +using System.Reactive.Linq; namespace Avalonia.Markup.Data { - internal class IndexerNode : ExpressionNode, - IWeakSubscriber, - IWeakSubscriber + internal class IndexerNode : ExpressionNode { public IndexerNode(IList arguments) { Arguments = arguments; } - public IList Arguments { get; } + public override string Description => "[" + string.Join(",", Arguments) + "]"; - void IWeakSubscriber.OnEvent(object sender, NotifyCollectionChangedEventArgs e) + protected override IObservable StartListeningCore(WeakReference reference) { - var update = false; - if (sender is IList) - { - object indexObject; - if (!TypeUtilities.TryConvert(typeof(int), Arguments[0], CultureInfo.InvariantCulture, out indexObject)) - { - return; - } - var index = (int)indexObject; - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - update = index >= e.NewStartingIndex; - break; - case NotifyCollectionChangedAction.Remove: - update = index >= e.OldStartingIndex; - break; - case NotifyCollectionChangedAction.Replace: - update = index >= e.NewStartingIndex && - index < e.NewStartingIndex + e.NewItems.Count; - break; - case NotifyCollectionChangedAction.Move: - update = (index >= e.NewStartingIndex && - index < e.NewStartingIndex + e.NewItems.Count) || - (index >= e.OldStartingIndex && - index < e.OldStartingIndex + e.OldItems.Count); - break; - case NotifyCollectionChangedAction.Reset: - update = true; - break; - } - } - else - { - update = true; - } - - if (update) - { - CurrentValue = new WeakReference(GetValue(sender)); - } - } - - void IWeakSubscriber.OnEvent(object sender, PropertyChangedEventArgs e) - { - var typeInfo = sender.GetType().GetTypeInfo(); - - if (typeInfo.GetDeclaredProperty(e.PropertyName) == null) - { - return; - } - - if (typeInfo.GetDeclaredProperty(e.PropertyName).GetIndexParameters().Any()) - { - CurrentValue = new WeakReference(GetValue(sender)); - } - } - - protected override void SubscribeAndUpdate(WeakReference reference) - { - object target = reference.Target; - - CurrentValue = new WeakReference(GetValue(target)); - + var target = reference.Target; var incc = target as INotifyCollectionChanged; - - if (incc != null) - { - WeakSubscriptionManager.Subscribe( - incc, - nameof(incc.CollectionChanged), - this); - } - var inpc = target as INotifyPropertyChanged; - - if (inpc != null) - { - WeakSubscriptionManager.Subscribe( - inpc, - nameof(inpc.PropertyChanged), - this); - } - } - - protected override void Unsubscribe(object target) - { - var incc = target as INotifyCollectionChanged; + var inputs = new List>(); if (incc != null) { - WeakSubscriptionManager.Unsubscribe( - incc, - nameof(incc.CollectionChanged), - this); + inputs.Add(WeakObservable.FromEventPattern( + target, + nameof(incc.CollectionChanged)) + .Where(x => ShouldUpdate(x.Sender, x.EventArgs)) + .Select(_ => GetValue(target))); } - var inpc = target as INotifyPropertyChanged; - if (inpc != null) { - WeakSubscriptionManager.Unsubscribe( - inpc, - nameof(inpc.PropertyChanged), - this); + inputs.Add(WeakObservable.FromEventPattern( + target, + nameof(inpc.PropertyChanged)) + .Where(x => ShouldUpdate(x.Sender, x.EventArgs)) + .Select(_ => GetValue(target))); } + + return Observable.Merge(inputs).StartWith(GetValue(target)); } + public IList Arguments { get; } + private object GetValue(object target) { var typeInfo = target.GetType().GetTypeInfo(); @@ -141,18 +60,23 @@ namespace Avalonia.Markup.Data var dictionary = target as IDictionary; var indexerProperty = GetIndexer(typeInfo); var indexerParameters = indexerProperty?.GetIndexParameters(); + if (indexerProperty != null && indexerParameters.Length == Arguments.Count) { var convertedObjectArray = new object[indexerParameters.Length]; + for (int i = 0; i < Arguments.Count; i++) { object temp = null; + if (!TypeUtilities.TryConvert(indexerParameters[i].ParameterType, Arguments[i], CultureInfo.InvariantCulture, out temp)) { return AvaloniaProperty.UnsetValue; } + convertedObjectArray[i] = temp; } + var intArgs = convertedObjectArray.OfType().ToArray(); // Try special cases where we can validate indicies @@ -166,16 +90,18 @@ namespace Avalonia.Markup.Data { if (intArgs.Length == Arguments.Count && intArgs[0] >= 0 && intArgs[0] < list.Count) { - return list[intArgs[0]]; + return list[intArgs[0]]; } + return AvaloniaProperty.UnsetValue; } else if (dictionary != null) { if (dictionary.Contains(convertedObjectArray[0])) { - return dictionary[convertedObjectArray[0]]; + return dictionary[convertedObjectArray[0]]; } + return AvaloniaProperty.UnsetValue; } else @@ -187,11 +113,11 @@ namespace Avalonia.Markup.Data else { // Fallback to unchecked access - return indexerProperty.GetValue(target, convertedObjectArray); + return indexerProperty.GetValue(target, convertedObjectArray); } } // Multidimensional arrays end up here because the indexer search picks up the IList indexer instead of the - // multidimensional indexer, which doesn't take the same number of arguments + // multidimensional indexer, which doesn't take the same number of arguments else if (typeInfo.IsArray) { return GetValueFromArray((Array)target); @@ -220,13 +146,16 @@ namespace Avalonia.Markup.Data private bool ConvertArgumentsToInts(out int[] intArgs) { intArgs = new int[Arguments.Count]; + for (int i = 0; i < Arguments.Count; ++i) { object value; + if (!TypeUtilities.TryConvert(typeof(int), Arguments[i], CultureInfo.InvariantCulture, out value)) { return false; } + intArgs[i] = (int)value; } return true; @@ -235,7 +164,8 @@ namespace Avalonia.Markup.Data private static PropertyInfo GetIndexer(TypeInfo typeInfo) { PropertyInfo indexer; - for (;typeInfo != null; typeInfo = typeInfo.BaseType?.GetTypeInfo()) + + for (; typeInfo != null; typeInfo = typeInfo.BaseType?.GetTypeInfo()) { // Check for the default indexer name first to make this faster. // This will only be false when a class in VB has a custom indexer name. @@ -243,14 +173,16 @@ namespace Avalonia.Markup.Data { return indexer; } + foreach (var property in typeInfo.DeclaredProperties) { if (property.GetIndexParameters().Any()) { return property; } - } + } } + return null; } @@ -273,5 +205,46 @@ namespace Avalonia.Markup.Data return false; } } + + private bool ShouldUpdate(object sender, NotifyCollectionChangedEventArgs e) + { + if (sender is IList) + { + object indexObject; + + if (!TypeUtilities.TryConvert(typeof(int), Arguments[0], CultureInfo.InvariantCulture, out indexObject)) + { + return false; + } + + var index = (int)indexObject; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + return index >= e.NewStartingIndex; + case NotifyCollectionChangedAction.Remove: + return index >= e.OldStartingIndex; + case NotifyCollectionChangedAction.Replace: + return index >= e.NewStartingIndex && + index < e.NewStartingIndex + e.NewItems.Count; + case NotifyCollectionChangedAction.Move: + return (index >= e.NewStartingIndex && + index < e.NewStartingIndex + e.NewItems.Count) || + (index >= e.OldStartingIndex && + index < e.OldStartingIndex + e.OldItems.Count); + case NotifyCollectionChangedAction.Reset: + return true; + } + } + + return false; + } + + private bool ShouldUpdate(object sender, PropertyChangedEventArgs e) + { + var typeInfo = sender.GetType().GetTypeInfo(); + return typeInfo.GetDeclaredProperty(e.PropertyName)?.GetIndexParameters().Any() ?? false; + } } } diff --git a/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs b/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs index 29fa842439..58a7915254 100644 --- a/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs +++ b/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs @@ -3,21 +3,17 @@ using System; using System.Globalization; -using System.Reactive.Linq; using Avalonia.Data; namespace Avalonia.Markup.Data { internal class LogicalNotNode : ExpressionNode { - public override bool SetValue(object value, BindingPriority priority) - { - return false; - } + public override string Description => "!"; - public override IDisposable Subscribe(IObserver observer) + protected override void NextValueChanged(object value) { - return Next.Select(Negate).Subscribe(observer); + base.NextValueChanged(Negate(value)); } private static object Negate(object v) @@ -34,6 +30,12 @@ namespace Avalonia.Markup.Data { return !result; } + else + { + return new BindingNotification( + new InvalidCastException($"Unable to convert '{s}' to bool."), + BindingErrorType.Error); + } } else { @@ -42,9 +44,17 @@ namespace Avalonia.Markup.Data var boolean = Convert.ToBoolean(v, CultureInfo.InvariantCulture); return !boolean; } - catch + catch (InvalidCastException) + { + // The error message here is "Unable to cast object of type 'System.Object' + // to type 'System.IConvertible'" which is kinda useless so provide our own. + return new BindingNotification( + new InvalidCastException($"Unable to convert '{v}' to bool."), + BindingErrorType.Error); + } + catch (Exception e) { - // TODO: Maybe should log something here. + return new BindingNotification(e, BindingErrorType.Error); } } } diff --git a/src/Markup/Avalonia.Markup/Data/MarkupBindingChainNullException.cs b/src/Markup/Avalonia.Markup/Data/MarkupBindingChainNullException.cs new file mode 100644 index 0000000000..a549d6ebb6 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/MarkupBindingChainNullException.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Data; + +namespace Avalonia.Markup.Data +{ + internal class MarkupBindingChainNullException : BindingChainNullException + { + private IList _nodes = new List(); + + public MarkupBindingChainNullException() + { + } + + public MarkupBindingChainNullException(string expression, string expressionNullPoint) + : base(expression, expressionNullPoint) + { + _nodes = null; + } + + public bool HasNodes => _nodes.Count > 0; + public void AddNode(string node) => _nodes.Add(node); + + public void Commit(string expression) + { + Expression = expression; + ExpressionNullPoint = string.Join(".", _nodes.Reverse()) + .Replace(".!", "!") + .Replace(".[", "["); + _nodes = null; + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs index b2d73ab8fc..1d4a9a688e 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs @@ -4,7 +4,6 @@ using System; using System.Reactive.Linq; using Avalonia.Data; -using Avalonia.Logging; namespace Avalonia.Markup.Data.Plugins { @@ -13,36 +12,22 @@ namespace Avalonia.Markup.Data.Plugins /// public class AvaloniaPropertyAccessorPlugin : IPropertyAccessorPlugin { - /// - /// Checks whether this plugin can handle accessing the properties of the specified object. - /// - /// A weak reference to the object. - /// True if the plugin can handle the object; otherwise false. - public bool Match(WeakReference reference) - { - Contract.Requires(reference != null); - - return reference.Target is AvaloniaObject; - } + /// + public bool Match(WeakReference reference) => reference.Target is AvaloniaObject; /// /// Starts monitoring the value of a property on an object. /// /// A weak reference to the object. /// The property name. - /// A function to call when the property changes. /// /// An interface through which future interactions with the /// property will be made. /// - public IPropertyAccessor Start( - WeakReference reference, - string propertyName, - Action changed) + public IPropertyAccessor Start(WeakReference reference, string propertyName) { Contract.Requires(reference != null); Contract.Requires(propertyName != null); - Contract.Requires(changed != null); var instance = reference.Target; var o = (AvaloniaObject)instance; @@ -50,13 +35,13 @@ namespace Avalonia.Markup.Data.Plugins if (p != null) { - return new Accessor(new WeakReference(o), p, changed); + return new Accessor(new WeakReference(o), p); } else if (instance != AvaloniaProperty.UnsetValue) { var message = $"Could not find AvaloniaProperty '{propertyName}' on '{instance}'"; var exception = new MissingMemberException(message); - return new PropertyError(new BindingError(exception)); + return new PropertyError(new BindingNotification(exception, BindingErrorType.Error)); } else { @@ -64,23 +49,19 @@ namespace Avalonia.Markup.Data.Plugins } } - private class Accessor : IPropertyAccessor + private class Accessor : PropertyAccessorBase { private readonly WeakReference _reference; private readonly AvaloniaProperty _property; private IDisposable _subscription; - public Accessor( - WeakReference reference, - AvaloniaProperty property, - Action changed) + public Accessor(WeakReference reference, AvaloniaProperty property) { Contract.Requires(reference != null); Contract.Requires(property != null); _reference = reference; _property = property; - _subscription = Instance.GetWeakObservable(property).Skip(1).Subscribe(changed); } public AvaloniaObject Instance @@ -93,17 +74,10 @@ namespace Avalonia.Markup.Data.Plugins } } - public Type PropertyType => _property.PropertyType; - - public object Value => Instance.GetValue(_property); + public override Type PropertyType => _property.PropertyType; + public override object Value => Instance?.GetValue(_property); - public void Dispose() - { - _subscription?.Dispose(); - _subscription = null; - } - - public bool SetValue(object value, BindingPriority priority) + public override bool SetValue(object value, BindingPriority priority) { if (!_property.IsReadOnly) { @@ -113,6 +87,17 @@ namespace Avalonia.Markup.Data.Plugins return false; } + + protected override void Dispose(bool disposing) + { + _subscription?.Dispose(); + _subscription = null; + } + + protected override void SubscribeCore(IObserver observer) + { + _subscription = Instance.GetWeakObservable(_property).Subscribe(observer); + } } } } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/DataAnnotationsValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/DataAnnotationsValidationPlugin.cs new file mode 100644 index 0000000000..859438636a --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/DataAnnotationsValidationPlugin.cs @@ -0,0 +1,81 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Validates properties on that have s. + /// + public class DataAnnotationsValidationPlugin : IDataValidationPlugin + { + /// + public bool Match(WeakReference reference, string memberName) + { + return reference.Target? + .GetType() + .GetRuntimeProperty(memberName)? + .GetCustomAttributes() + .Any() ?? false; + } + + /// + public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor inner) + { + return new Accessor(reference, name, inner); + } + + private class Accessor : DataValidatiorBase + { + private ValidationContext _context; + + public Accessor(WeakReference reference, string name, IPropertyAccessor inner) + : base(inner) + { + _context = new ValidationContext(reference.Target); + _context.MemberName = name; + } + + public override bool SetValue(object value, BindingPriority priority) + { + return base.SetValue(value, priority); + } + + protected override void InnerValueChanged(object value) + { + var errors = new List(); + + if (Validator.TryValidateProperty(value, _context, errors)) + { + base.InnerValueChanged(value); + } + else + { + base.InnerValueChanged(new BindingNotification( + CreateException(errors), + BindingErrorType.DataValidationError, + value)); + } + } + + private Exception CreateException(IList errors) + { + if (errors.Count == 1) + { + return new ValidationException(errors[0].ErrorMessage); + } + else + { + return new AggregateException( + errors.Select(x => new ValidationException(x.ErrorMessage))); + } + } + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs b/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs new file mode 100644 index 0000000000..95d269f437 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs @@ -0,0 +1,80 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Base class for data validators. + /// + /// + /// Data validators are s that are returned from an + /// . They wrap an inner + /// and convert any values received from the inner property accessor into + /// s. + /// + public abstract class DataValidatiorBase : PropertyAccessorBase, IObserver + { + private readonly IPropertyAccessor _inner; + + /// + /// Initializes a new instance of the class. + /// + /// The inner property accessor. + protected DataValidatiorBase(IPropertyAccessor inner) + { + _inner = inner; + } + + /// + public override Type PropertyType => _inner.PropertyType; + + /// + public override object Value => _inner.Value; + + /// + public override bool SetValue(object value, BindingPriority priority) => _inner.SetValue(value, priority); + + /// + /// Should never be called: the inner should never notify + /// completion. + /// + void IObserver.OnCompleted() { } + + /// + /// Should never be called: the inner should never notify + /// an error. + /// + void IObserver.OnError(Exception error) { } + + /// + /// Called when the inner notifies with a new value. + /// + /// The value. + void IObserver.OnNext(object value) => InnerValueChanged(value); + + /// + protected override void Dispose(bool disposing) => _inner.Dispose(); + + /// + /// Begins listening to the inner . + /// + protected override void SubscribeCore(IObserver observer) => _inner.Subscribe(this); + + /// + /// Called when the inner notifies with a new value. + /// + /// The value. + /// + /// Notifies the observer that the value has changed. The value will be wrapped in a + /// if it is not already a binding notification. + /// + protected virtual void InnerValueChanged(object value) + { + var notification = value as BindingNotification ?? new BindingNotification(value); + Observer.OnNext(notification); + } + } +} \ No newline at end of file diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs index 95a32a6928..e0b6bcfd7c 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs @@ -10,23 +10,21 @@ namespace Avalonia.Markup.Data.Plugins /// /// Validates properties that report errors by throwing exceptions. /// - public class ExceptionValidationPlugin : IValidationPlugin + public class ExceptionValidationPlugin : IDataValidationPlugin { - public static ExceptionValidationPlugin Instance { get; } = new ExceptionValidationPlugin(); - /// - public bool Match(WeakReference reference) => true; + public bool Match(WeakReference reference, string memberName) => true; /// - public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) + public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor inner) { - return new ExceptionValidationChecker(reference, name, accessor, callback); + return new Validator(reference, name, inner); } - private class ExceptionValidationChecker : ValidatingPropertyAccessorBase + private class Validator : DataValidatiorBase { - public ExceptionValidationChecker(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) - : base(reference, name, accessor, callback) + public Validator(WeakReference reference, string name, IPropertyAccessor inner) + : base(inner) { } @@ -34,39 +32,19 @@ namespace Avalonia.Markup.Data.Plugins { try { - var success = base.SetValue(value, priority); - SendValidationCallback(new ExceptionValidationStatus(null)); - return success; + return base.SetValue(value, priority); } catch (TargetInvocationException ex) { - SendValidationCallback(new ExceptionValidationStatus(ex.InnerException)); + Observer.OnNext(new BindingNotification(ex.InnerException, BindingErrorType.DataValidationError)); } catch (Exception ex) { - SendValidationCallback(new ExceptionValidationStatus(ex)); + Observer.OnNext(new BindingNotification(ex, BindingErrorType.DataValidationError)); } - return false; - } - } - /// - /// Describes the current validation status after setting a property value. - /// - public class ExceptionValidationStatus : IValidationStatus - { - internal ExceptionValidationStatus(Exception exception) - { - Exception = exception; + return false; } - - /// - /// The thrown exception. If there was no thrown exception, null. - /// - public Exception Exception { get; } - - /// - public bool IsValid => Exception == null; } } } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs new file mode 100644 index 0000000000..0952e2edab --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs @@ -0,0 +1,37 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Defines how data validation is observed by an . + /// + public interface IDataValidationPlugin + { + /// + /// Checks whether this plugin can handle data validation on the specified object. + /// + /// A weak reference to the object. + /// The name of the member to validate. + /// True if the plugin can handle the object; otherwise false. + bool Match(WeakReference reference, string memberName); + + /// + /// Starts monitoring the data validation state of a property on an object. + /// + /// A weak reference to the object. + /// The property name. + /// The inner property accessor used to aceess the property. + /// + /// An interface through which future interactions with the + /// property will be made. + /// + IPropertyAccessor Start( + WeakReference reference, + string propertyName, + IPropertyAccessor inner); + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IPropertyAccessor.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IPropertyAccessor.cs index d9da4b906f..9e686baf10 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IPropertyAccessor.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IPropertyAccessor.cs @@ -10,11 +10,14 @@ namespace Avalonia.Markup.Data.Plugins /// Defines an accessor to a property on an object returned by a /// /// - public interface IPropertyAccessor : IDisposable + public interface IPropertyAccessor : IObservable, IDisposable { /// /// Gets the type of the property. /// + /// + /// The accessor has not been subscribed to yet. + /// Type PropertyType { get; } /// diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IPropertyAccessorPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IPropertyAccessorPlugin.cs index 0d6b57e424..187142bd0e 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IPropertyAccessorPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IPropertyAccessorPlugin.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Collections; namespace Avalonia.Markup.Data.Plugins { @@ -24,14 +23,12 @@ namespace Avalonia.Markup.Data.Plugins /// /// A weak reference to the object. /// The property name. - /// A function to call when the property changes. /// /// An interface through which future interactions with the /// property will be made. /// IPropertyAccessor Start( WeakReference reference, - string propertyName, - Action changed); + string propertyName); } } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IValidationPlugin.cs deleted file mode 100644 index e42e1ad7b2..0000000000 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IValidationPlugin.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using Avalonia.Data; - -namespace Avalonia.Markup.Data.Plugins -{ - /// - /// Defines how view model data validation is observed by an . - /// - public interface IValidationPlugin - { - - /// - /// Checks whether the data uses a validation scheme supported by this plugin. - /// - /// A weak reference to the data. - /// true if this plugin can observe the validation; otherwise, false. - bool Match(WeakReference reference); - - /// - /// Starts monitoring the validation state of an object for the given property. - /// - /// A weak reference to the object. - /// The property name. - /// An underlying to access the property. - /// A function to call when the validation state changes. - /// - /// A subclass through which future interactions with the - /// property will be made. - /// - IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor accessor, Action callback); - } -} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IValuePlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IValuePlugin.cs new file mode 100644 index 0000000000..fb285c6d73 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IValuePlugin.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Defines how values are observed by an . + /// + public interface IValuePlugin + { + /// + /// Checks whether this plugin handles the specified value. + /// + /// A weak reference to the value. + /// True if the plugin can handle the value; otherwise false. + bool Match(WeakReference reference); + + /// + /// Starts producing output based on the specified value. + /// + /// A weak reference to the object. + /// + /// An observable that produces the output for the value. + /// + IObservable Start(WeakReference reference); + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs index f6b4aea614..82bc87c207 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs @@ -2,7 +2,7 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Collections; +using System.Collections.Generic; using System.ComponentModel; using System.Linq; using Avalonia.Data; @@ -13,79 +13,106 @@ namespace Avalonia.Markup.Data.Plugins /// /// Validates properties on objects that implement . /// - public class IndeiValidationPlugin : IValidationPlugin + public class IndeiValidationPlugin : IDataValidationPlugin { /// - public bool Match(WeakReference reference) - { - return reference.Target is INotifyDataErrorInfo; - } + public bool Match(WeakReference reference, string memberName) => reference.Target is INotifyDataErrorInfo; /// - public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) + public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor accessor) { - return new IndeiValidationChecker(reference, name, accessor, callback); + return new Validator(reference, name, accessor); } - private class IndeiValidationChecker : ValidatingPropertyAccessorBase, IWeakSubscriber + private class Validator : DataValidatiorBase, IWeakSubscriber { - public IndeiValidationChecker(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) - : base(reference, name, accessor, callback) + WeakReference _reference; + string _name; + + public Validator(WeakReference reference, string name, IPropertyAccessor inner) + : base(inner) + { + _reference = reference; + _name = name; + } + + void IWeakSubscriber.OnEvent(object sender, DataErrorsChangedEventArgs e) { - var target = reference.Target as INotifyDataErrorInfo; + if (e.PropertyName == _name || string.IsNullOrEmpty(e.PropertyName)) + { + Observer.OnNext(CreateBindingNotification(Value)); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + var target = _reference.Target as INotifyDataErrorInfo; + if (target != null) { - if (target.HasErrors) - { - SendValidationCallback(new IndeiValidationStatus(target.GetErrors(name))); - } - WeakSubscriptionManager.Subscribe( + WeakSubscriptionManager.Unsubscribe( target, nameof(target.ErrorsChanged), this); } } - public override void Dispose() + protected override void SubscribeCore(IObserver observer) { - base.Dispose(); var target = _reference.Target as INotifyDataErrorInfo; + if (target != null) { - WeakSubscriptionManager.Unsubscribe( + WeakSubscriptionManager.Subscribe( target, nameof(target.ErrorsChanged), this); } + + base.SubscribeCore(observer); } - public void OnEvent(object sender, DataErrorsChangedEventArgs e) + protected override void InnerValueChanged(object value) { - if (e.PropertyName == _name || string.IsNullOrEmpty(e.PropertyName)) + base.InnerValueChanged(CreateBindingNotification(value)); + } + + private BindingNotification CreateBindingNotification(object value) + { + var target = (INotifyDataErrorInfo)_reference.Target; + + if (target != null) { - var indei = _reference.Target as INotifyDataErrorInfo; - SendValidationCallback(new IndeiValidationStatus(indei.GetErrors(e.PropertyName))); + var errors = target.GetErrors(_name)? + .Cast() + .Where(x => x != null).ToList(); + + if (errors?.Count > 0) + { + return new BindingNotification( + GenerateException(errors), + BindingErrorType.DataValidationError, + value); + } } + + return new BindingNotification(value); } - } - /// - /// Describes the current validation status of a property as reported by an object that implements . - /// - public class IndeiValidationStatus : IValidationStatus - { - internal IndeiValidationStatus(IEnumerable errors) + private Exception GenerateException(IList errors) { - Errors = errors; + if (errors.Count == 1) + { + return new Exception(errors[0]); + } + else + { + return new AggregateException( + errors.Select(x => new Exception(x))); + } } - - /// - public bool IsValid => !Errors?.OfType().Any() ?? true; - - /// - /// The errors on the given property and on the object as a whole. - /// - public IEnumerable Errors { get; } } } } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs index 36046dd742..138f09b373 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs @@ -9,7 +9,6 @@ using System.Reflection; using Avalonia.Data; using Avalonia.Logging; using Avalonia.Utilities; -using System.Collections; namespace Avalonia.Markup.Data.Plugins { @@ -19,124 +18,130 @@ namespace Avalonia.Markup.Data.Plugins /// public class InpcPropertyAccessorPlugin : IPropertyAccessorPlugin { - /// - /// Checks whether this plugin can handle accessing the properties of the specified object. - /// - /// The object. - /// True if the plugin can handle the object; otherwise false. - public bool Match(WeakReference reference) - { - Contract.Requires(reference != null); - - return true; - } + /// + public bool Match(WeakReference reference) => true; /// /// Starts monitoring the value of a property on an object. /// /// The object. /// The property name. - /// A function to call when the property changes. /// /// An interface through which future interactions with the /// property will be made. /// - public IPropertyAccessor Start( - WeakReference reference, - string propertyName, - Action changed) + public IPropertyAccessor Start(WeakReference reference, string propertyName) { Contract.Requires(reference != null); Contract.Requires(propertyName != null); - Contract.Requires(changed != null); var instance = reference.Target; var p = instance.GetType().GetRuntimeProperties().FirstOrDefault(_ => _.Name == propertyName); if (p != null) { - return new Accessor(reference, p, changed); + return new Accessor(reference, p); } else { var message = $"Could not find CLR property '{propertyName}' on '{instance}'"; var exception = new MissingMemberException(message); - return new PropertyError(new BindingError(exception)); + return new PropertyError(new BindingNotification(exception, BindingErrorType.Error)); } } - private class Accessor : IPropertyAccessor, IWeakSubscriber + private class Accessor : PropertyAccessorBase, IWeakSubscriber { private readonly WeakReference _reference; private readonly PropertyInfo _property; - private readonly Action _changed; + private bool _eventRaised; - public Accessor( - WeakReference reference, - PropertyInfo property, - Action changed) + public Accessor(WeakReference reference, PropertyInfo property) { Contract.Requires(reference != null); Contract.Requires(property != null); _reference = reference; _property = property; - _changed = changed; + } - var inpc = reference.Target as INotifyPropertyChanged; + public override Type PropertyType => _property.PropertyType; - if (inpc != null) + public override object Value + { + get { - WeakSubscriptionManager.Subscribe( - inpc, - nameof(inpc.PropertyChanged), - this); + var o = _reference.Target; + return (o != null) ? _property.GetValue(o) : null; } - else + } + + public override bool SetValue(object value, BindingPriority priority) + { + if (_property.CanWrite) { - Logger.Warning( - LogArea.Binding, - this, - "Bound to property {Property} on {Source} which does not implement INotifyPropertyChanged", - property.Name, - reference.Target, - reference.Target.GetType()); + _eventRaised = false; + _property.SetValue(_reference.Target, value); + + if (!_eventRaised) + { + SendCurrentValue(); + } + + return true; } - } - public Type PropertyType => _property.PropertyType; + return false; + } - public object Value => _property.GetValue(_reference.Target); + void IWeakSubscriber.OnEvent(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == _property.Name || string.IsNullOrEmpty(e.PropertyName)) + { + _eventRaised = true; + SendCurrentValue(); + } + } - public void Dispose() + protected override void Dispose(bool disposing) { var inpc = _reference.Target as INotifyPropertyChanged; if (inpc != null) { - WeakSubscriptionManager.Unsubscribe( + WeakSubscriptionManager.Unsubscribe( inpc, nameof(inpc.PropertyChanged), this); } } - public bool SetValue(object value, BindingPriority priority) + protected override void SubscribeCore(IObserver observer) { - if (_property.CanWrite) + SendCurrentValue(); + SubscribeToChanges(); + } + + private void SendCurrentValue() + { + try { - _property.SetValue(_reference.Target, value); - return true; + var value = Value; + Observer.OnNext(value); } - - return false; + catch { } } - void IWeakSubscriber.OnEvent(object sender, PropertyChangedEventArgs e) + private void SubscribeToChanges() { - if (e.PropertyName == _property.Name || string.IsNullOrEmpty(e.PropertyName)) + var inpc = _reference.Target as INotifyPropertyChanged; + + if (inpc != null) { - _changed(Value); + WeakSubscriptionManager.Subscribe( + inpc, + nameof(inpc.PropertyChanged), + this); } } } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/ObservableValuePlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/ObservableValuePlugin.cs new file mode 100644 index 0000000000..a406fc55b9 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/ObservableValuePlugin.cs @@ -0,0 +1,44 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows.Input; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Handles binding to s in an . + /// + public class ObservableValuePlugin : IValuePlugin + { + /// + /// Checks whether this plugin handles the specified value. + /// + /// A weak reference to the value. + /// True if the plugin can handle the value; otherwise false. + public virtual bool Match(WeakReference reference) + { + var target = reference.Target; + + // ReactiveCommand is an IObservable but we want to bind to it, not its value. + return target is IObservable && !(target is ICommand); + } + + /// + /// Starts producing output based on the specified value. + /// + /// A weak reference to the object. + /// + /// An observable that produces the output for the value. + /// + public virtual IObservable Start(WeakReference reference) + { + return reference.Target as IObservable; + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/PropertyAccessorBase.cs b/src/Markup/Avalonia.Markup/Data/Plugins/PropertyAccessorBase.cs new file mode 100644 index 0000000000..9aa858e0eb --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/PropertyAccessorBase.cs @@ -0,0 +1,68 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Defines a default base implementation for a . + /// + /// + /// is an observable that will only be subscribed to one time. + /// In addition, the subscription can be disposed by calling on the + /// property accessor itself - this prevents needing to hold two references for a subscription. + /// + public abstract class PropertyAccessorBase : IPropertyAccessor + { + /// + public abstract Type PropertyType { get; } + + /// + public abstract object Value { get; } + + /// + /// Stops the subscription. + /// + public void Dispose() => Dispose(true); + + /// + public abstract bool SetValue(object value, BindingPriority priority); + + /// + /// The currently subscribed observer. + /// + protected IObserver Observer { get; private set; } + + /// + public IDisposable Subscribe(IObserver observer) + { + Contract.Requires(observer != null); + + if (Observer != null) + { + throw new InvalidOperationException( + "A property accessor can be subscribed to only once."); + } + + Observer = observer; + SubscribeCore(observer); + return this; + } + + /// + /// Stops listening to the property. + /// + /// + /// True if the method was called, false if the object is being + /// finalized. + /// + protected virtual void Dispose(bool disposing) => Observer = null; + + /// + /// When overridden in a derived class, begins listening to the property. + /// + protected abstract void SubscribeCore(IObserver observer); + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/PropertyError.cs b/src/Markup/Avalonia.Markup/Data/Plugins/PropertyError.cs index f73bb1fc94..b351ef39bd 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/PropertyError.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/PropertyError.cs @@ -1,4 +1,5 @@ using System; +using System.Reactive.Disposables; using Avalonia.Data; namespace Avalonia.Markup.Data.Plugins @@ -8,13 +9,13 @@ namespace Avalonia.Markup.Data.Plugins /// public class PropertyError : IPropertyAccessor { - private BindingError _error; + private BindingNotification _error; /// /// Initializes a new instance of the class. /// /// The error to report. - public PropertyError(BindingError error) + public PropertyError(BindingNotification error) { _error = error; } @@ -35,5 +36,11 @@ namespace Avalonia.Markup.Data.Plugins { return false; } + + public IDisposable Subscribe(IObserver observer) + { + observer.OnNext(_error); + return Disposable.Empty; + } } } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/TaskValuePlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/TaskValuePlugin.cs new file mode 100644 index 0000000000..b6fda67503 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/TaskValuePlugin.cs @@ -0,0 +1,82 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Handles binding to s in an . + /// + public class TaskValuePlugin : IValuePlugin + { + /// + /// Checks whether this plugin handles the specified value. + /// + /// A weak reference to the value. + /// True if the plugin can handle the value; otherwise false. + public virtual bool Match(WeakReference reference) => reference.Target is Task; + + /// + /// Starts producing output based on the specified value. + /// + /// A weak reference to the object. + /// + /// An observable that produces the output for the value. + /// + public virtual IObservable Start(WeakReference reference) + { + var task = reference.Target as Task; + + if (task != null) + { + var resultProperty = task.GetType().GetTypeInfo().GetDeclaredProperty("Result"); + + if (resultProperty != null) + { + switch (task.Status) + { + case TaskStatus.RanToCompletion: + case TaskStatus.Faulted: + return HandleCompleted(task); + default: + var subject = new Subject(); + task.ContinueWith( + x => HandleCompleted(task).Subscribe(subject), + TaskScheduler.FromCurrentSynchronizationContext()) + .ConfigureAwait(false); + return subject; + } + } + } + + return Observable.Empty(); + } + + protected IObservable HandleCompleted(Task task) + { + var resultProperty = task.GetType().GetTypeInfo().GetDeclaredProperty("Result"); + + if (resultProperty != null) + { + switch (task.Status) + { + case TaskStatus.RanToCompletion: + return Observable.Return(resultProperty.GetValue(task)); + case TaskStatus.Faulted: + return Observable.Return(new BindingNotification(task.Exception, BindingErrorType.Error)); + default: + throw new AvaloniaInternalException("HandleCompleted called for non-completed Task."); + } + } + + return Observable.Empty(); + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/ValidatingPropertyAccessorBase.cs b/src/Markup/Avalonia.Markup/Data/Plugins/ValidatingPropertyAccessorBase.cs deleted file mode 100644 index 07ac600a8a..0000000000 --- a/src/Markup/Avalonia.Markup/Data/Plugins/ValidatingPropertyAccessorBase.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using Avalonia.Data; - -namespace Avalonia.Markup.Data.Plugins -{ - - /// - /// A base class for validating s that wraps an and forwards method calls to it. - /// - public abstract class ValidatingPropertyAccessorBase : IPropertyAccessor - { - protected readonly WeakReference _reference; - protected readonly string _name; - private readonly IPropertyAccessor _accessor; - private readonly Action _callback; - - protected ValidatingPropertyAccessorBase(WeakReference reference, string name, IPropertyAccessor accessor, Action callback) - { - _reference = reference; - _name = name; - _accessor = accessor; - _callback = callback; - } - - /// - public Type PropertyType => _accessor.PropertyType; - - /// - public object Value => _accessor.Value; - - /// - public virtual void Dispose() => _accessor.Dispose(); - - /// - public virtual bool SetValue(object value, BindingPriority priority) => _accessor.SetValue(value, priority); - - /// - /// Sends the validation status to the callback specified in construction. - /// - /// The validation status. - protected void SendValidationCallback(IValidationStatus status) - { - _callback?.Invoke(status); - } - } -} \ No newline at end of file diff --git a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs index b12fbe90eb..7fb3137417 100644 --- a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs +++ b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs @@ -3,11 +3,8 @@ using System; using System.Linq; +using System.Reactive.Disposables; using System.Reactive.Linq; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Input; using Avalonia.Data; using Avalonia.Markup.Data.Plugins; @@ -15,9 +12,8 @@ namespace Avalonia.Markup.Data { internal class PropertyAccessorNode : ExpressionNode { + private readonly bool _enableValidation; private IPropertyAccessor _accessor; - private IDisposable _subscription; - private bool _enableValidation; public PropertyAccessorNode(string propertyName, bool enableValidation) { @@ -25,118 +21,44 @@ namespace Avalonia.Markup.Data _enableValidation = enableValidation; } + public override string Description => PropertyName; public string PropertyName { get; } - public Type PropertyType => _accessor?.PropertyType; - public override bool SetValue(object value, BindingPriority priority) + public bool SetTargetValue(object value, BindingPriority priority) { - if (Next != null) + if (_accessor != null) { - return Next.SetValue(value, priority); + try { return _accessor.SetValue(value, priority); } catch { } } - else - { - if (_accessor != null) - { - return _accessor.SetValue(value, priority); - } - return false; - } + return false; } - protected override void SubscribeAndUpdate(WeakReference reference) + protected override IObservable StartListeningCore(WeakReference reference) { - var instance = reference.Target; + var plugin = ExpressionObserver.PropertyAccessors.FirstOrDefault(x => x.Match(reference)); + var accessor = plugin?.Start(reference, PropertyName); - if (instance != null && instance != AvaloniaProperty.UnsetValue) + if (_enableValidation && Next == null) { - var accessorPlugin = ExpressionObserver.PropertyAccessors.FirstOrDefault(x => x.Match(reference)); - - if (accessorPlugin != null) + foreach (var validator in ExpressionObserver.DataValidators) { - _accessor = ExceptionValidationPlugin.Instance.Start( - reference, - PropertyName, - accessorPlugin.Start(reference, PropertyName, SetCurrentValue), - SendValidationStatus); - - if (_enableValidation) - { - foreach (var validationPlugin in ExpressionObserver.ValidationCheckers) - { - if (validationPlugin.Match(reference)) - { - _accessor = validationPlugin.Start(reference, PropertyName, _accessor, SendValidationStatus); - } - } - } - - if (_accessor != null) + if (validator.Match(reference, PropertyName)) { - SetCurrentValue(_accessor.Value); - return; + accessor = validator.Start(reference, PropertyName, accessor); } } } - CurrentValue = UnsetReference; - } - - protected override void Unsubscribe(object target) - { - _accessor?.Dispose(); - _accessor = null; - } - - private void SetCurrentValue(object value) - { - var observable = value as IObservable; - var command = value as ICommand; - var task = value as Task; - bool set = false; - - // HACK: ReactiveCommand is an IObservable but we want to bind to it, not its value. - // We may need to make this a more general solution. - if (observable != null && command == null) - { - CurrentValue = UnsetReference; - set = true; - _subscription = observable - .ObserveOn(SynchronizationContext.Current) - .Subscribe(x => CurrentValue = new WeakReference(x)); - } - else if (task != null) - { - var resultProperty = task.GetType().GetTypeInfo().GetDeclaredProperty("Result"); - - if (resultProperty != null) + // Ensure that _accessor is set for the duration of the subscription. + return Observable.Using( + () => { - if (task.Status == TaskStatus.RanToCompletion) - { - CurrentValue = new WeakReference(resultProperty.GetValue(task)); - set = true; - } - else - { - task.ContinueWith( - x => CurrentValue = new WeakReference(resultProperty.GetValue(task)), - TaskScheduler.FromCurrentSynchronizationContext()) - .ConfigureAwait(false); - } - } - } - else - { - CurrentValue = new WeakReference(value); - set = true; - } - - if (!set) - { - CurrentValue = UnsetReference; - } + _accessor = accessor; + return Disposable.Create(() => _accessor = null); + }, + _ => accessor); } } } diff --git a/src/Markup/Avalonia.Markup/DefaultValueConverter.cs b/src/Markup/Avalonia.Markup/DefaultValueConverter.cs index 469b00d3ad..86d37d8e13 100644 --- a/src/Markup/Avalonia.Markup/DefaultValueConverter.cs +++ b/src/Markup/Avalonia.Markup/DefaultValueConverter.cs @@ -43,8 +43,18 @@ namespace Avalonia.Markup if (value != null) { - var message = $"Could not convert '{value}' to '{targetType}'"; - return new BindingError(new InvalidCastException(message)); + string message; + + if (TypeUtilities.IsNumeric(targetType)) + { + message = $"'{value}' is not a valid number."; + } + else + { + message = $"Could not convert '{value}' to '{targetType.Name}'."; + } + + return new BindingNotification(new InvalidCastException(message), BindingErrorType.Error); } return AvaloniaProperty.UnsetValue; diff --git a/src/Markup/Avalonia.Markup/IValueConverter.cs b/src/Markup/Avalonia.Markup/IValueConverter.cs index 23117a3fac..10d5c626c2 100644 --- a/src/Markup/Avalonia.Markup/IValueConverter.cs +++ b/src/Markup/Avalonia.Markup/IValueConverter.cs @@ -3,6 +3,7 @@ using System; using System.Globalization; +using Avalonia.Data; namespace Avalonia.Markup { @@ -21,8 +22,8 @@ namespace Avalonia.Markup /// The converted value. /// /// This method should not throw exceptions. If the value is not convertible, return - /// . Any exception thrown will be treated as - /// an application exception. + /// a in an error state. Any exceptions thrown will be + /// treated as an application exception. /// object Convert(object value, Type targetType, object parameter, CultureInfo culture); @@ -36,8 +37,8 @@ namespace Avalonia.Markup /// The converted value. /// /// This method should not throw exceptions. If the value is not convertible, return - /// . Any exception thrown will be treated as - /// an application exception. + /// a in an error state. Any exceptions thrown will be + /// treated as an application exception. /// object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture); } diff --git a/src/Markup/Avalonia.Markup/Properties/AssemblyInfo.cs b/src/Markup/Avalonia.Markup/Properties/AssemblyInfo.cs index d74ccf00a1..dd8c0a6bd3 100644 --- a/src/Markup/Avalonia.Markup/Properties/AssemblyInfo.cs +++ b/src/Markup/Avalonia.Markup/Properties/AssemblyInfo.cs @@ -8,4 +8,3 @@ using System.Runtime.CompilerServices; [assembly: AssemblyTitle("Avalonia.Markup")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Markup")] [assembly: InternalsVisibleTo("Avalonia.Markup.UnitTests")] -[assembly: InternalsVisibleTo("Avalonia.Markup.Xaml")] \ No newline at end of file diff --git a/src/Markup/Avalonia.Markup/packages.config b/src/Markup/Avalonia.Markup/packages.config index 9f732f1bcb..bcef21429a 100644 --- a/src/Markup/Avalonia.Markup/packages.config +++ b/src/Markup/Avalonia.Markup/packages.config @@ -1,5 +1,6 @@  + diff --git a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj index 6e4278fdfc..07ed7f14ca 100644 --- a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj +++ b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj @@ -90,6 +90,7 @@ + diff --git a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.v2.ncrunchproject b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.v2.ncrunchproject index 30815b1937..b5cd70b13f 100644 --- a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.v2.ncrunchproject +++ b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.v2.ncrunchproject @@ -17,10 +17,11 @@ true true 60000 - - - + + + AutoDetect STA x86 + LongTestTimesWithoutParallelExecution \ No newline at end of file diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs index 2e7db2da05..66fe3c7767 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs @@ -54,6 +54,36 @@ namespace Avalonia.Base.UnitTests Assert.False(target.IsSet(Class1.QuxProperty)); } + [Fact] + public void OneTime_Binding_Ignores_UnsetValue() + { + var target = new Class1(); + var source = new Subject(); + + target.Bind(Class1.QuxProperty, new TestOneTimeBinding(source)); + + source.OnNext(AvaloniaProperty.UnsetValue); + Assert.Equal(5.6, target.GetValue(Class1.QuxProperty)); + + source.OnNext(6.7); + Assert.Equal(6.7, target.GetValue(Class1.QuxProperty)); + } + + [Fact] + public void OneTime_Binding_Ignores_Binding_Errors() + { + var target = new Class1(); + var source = new Subject(); + + target.Bind(Class1.QuxProperty, new TestOneTimeBinding(source)); + + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.Error)); + Assert.Equal(5.6, target.GetValue(Class1.QuxProperty)); + + source.OnNext(6.7); + Assert.Equal(6.7, target.GetValue(Class1.QuxProperty)); + } + [Fact] public void Bind_Throws_Exception_For_Unregistered_Property() { @@ -273,31 +303,36 @@ namespace Avalonia.Base.UnitTests target.Bind(Class1.QuxProperty, source); source.OnNext(6.7); - source.OnNext(new BindingError(new InvalidOperationException("Foo"))); + source.OnNext(new BindingNotification( + new InvalidOperationException("Foo"), + BindingErrorType.Error)); Assert.Equal(6.7, target.GetValue(Class1.QuxProperty)); } [Fact] - public void BindingError_With_FallbackValue_Causes_Target_Update() + public void BindingNotification_With_FallbackValue_Causes_Target_Update() { var target = new Class1(); var source = new Subject(); target.Bind(Class1.QuxProperty, source); source.OnNext(6.7); - source.OnNext(new BindingError(new InvalidOperationException("Foo"), 8.9)); + source.OnNext(new BindingNotification( + new InvalidOperationException("Foo"), + BindingErrorType.Error, + 8.9)); Assert.Equal(8.9, target.GetValue(Class1.QuxProperty)); } [Fact] - public void Bind_Logs_BindingError() + public void Bind_Logs_Binding_Error() { var target = new Class1(); var source = new Subject(); var called = false; - var expectedMessageTemplate = "Error binding to {Target}.{Property}: {Message}"; + var expectedMessageTemplate = "Error in binding to {Target}.{Property}: {Message}"; LogCallback checkLogMessage = (level, area, src, mt, pv) => { @@ -313,7 +348,9 @@ namespace Avalonia.Base.UnitTests { target.Bind(Class1.QuxProperty, source); source.OnNext(6.7); - source.OnNext(new BindingError(new InvalidOperationException("Foo"))); + source.OnNext(new BindingNotification( + new InvalidOperationException("Foo"), + BindingErrorType.Error)); Assert.Equal(6.7, target.GetValue(Class1.QuxProperty)); Assert.True(called); @@ -345,5 +382,24 @@ namespace Avalonia.Base.UnitTests public static readonly StyledProperty BarProperty = AvaloniaProperty.Register("Bar", "bardefault"); } + + private class TestOneTimeBinding : IBinding + { + private IObservable _source; + + public TestOneTimeBinding(IObservable source) + { + _source = source; + } + + public InstancedBinding Initiate( + IAvaloniaObject target, + AvaloniaProperty targetProperty, + object anchor = null, + bool enableDataValidation = false) + { + return new InstancedBinding(_source, BindingMode.OneTime); + } + } } } diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs new file mode 100644 index 0000000000..c8436c376f --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Subjects; +using Avalonia.Data; +using Xunit; + +namespace Avalonia.Base.UnitTests +{ + public class AvaloniaObjectTests_DataValidation + { + [Fact] + public void Setting_Non_Validated_Property_Does_Not_Call_UpdateDataValidation() + { + var target = new Class1(); + + target.SetValue(Class1.NonValidatedDirectProperty, 6); + + Assert.Empty(target.Notifications); + } + + [Fact] + public void Setting_Non_Validated_Direct_Property_Does_Not_Call_UpdateDataValidation() + { + var target = new Class1(); + + target.SetValue(Class1.NonValidatedDirectProperty, 6); + + Assert.Empty(target.Notifications); + } + + [Fact] + public void Setting_Validated_Direct_Property_Calls_UpdateDataValidation() + { + var target = new Class1(); + + target.SetValue(Class1.ValidatedDirectProperty, new BindingNotification(6)); + target.SetValue(Class1.ValidatedDirectProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); + target.SetValue(Class1.ValidatedDirectProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + target.SetValue(Class1.ValidatedDirectProperty, new BindingNotification(7)); + + Assert.Equal( + new[] + { + new BindingNotification(6), + new BindingNotification(new Exception(), BindingErrorType.Error), + new BindingNotification(new Exception(), BindingErrorType.DataValidationError), + new BindingNotification(7), + }, + target.Notifications.AsEnumerable()); + } + + [Fact] + public void Binding_Non_Validated_Property_Does_Not_Call_UpdateDataValidation() + { + var source = new Subject(); + var target = new Class1 + { + [!Class1.NonValidatedProperty] = source.AsBinding(), + }; + + source.OnNext(new BindingNotification(6)); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.Error)); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + source.OnNext(new BindingNotification(7)); + + Assert.Empty(target.Notifications); + } + + [Fact] + public void Binding_Validated_Direct_Property_Calls_UpdateDataValidation() + { + var source = new Subject(); + var target = new Class1 + { + [!Class1.ValidatedDirectProperty] = source.AsBinding(), + }; + + source.OnNext(new BindingNotification(6)); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.Error)); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + source.OnNext(new BindingNotification(7)); + + Assert.Equal( + new[] + { + new BindingNotification(6), + new BindingNotification(new Exception(), BindingErrorType.Error), + new BindingNotification(new Exception(), BindingErrorType.DataValidationError), + new BindingNotification(7), + }, + target.Notifications.AsEnumerable()); + } + + private class Class1 : AvaloniaObject + { + public static readonly StyledProperty NonValidatedProperty = + AvaloniaProperty.Register( + nameof(NonValidated)); + + public static readonly DirectProperty NonValidatedDirectProperty = + AvaloniaProperty.RegisterDirect( + nameof(NonValidatedDirect), + o => o.NonValidatedDirect, + (o, v) => o.NonValidatedDirect = v); + + public static readonly DirectProperty ValidatedDirectProperty = + AvaloniaProperty.RegisterDirect( + nameof(ValidatedDirect), + o => o.ValidatedDirect, + (o, v) => o.ValidatedDirect = v, + enableDataValidation: true); + + private int _nonValidatedDirect; + private int _direct; + + public int NonValidated + { + get { return GetValue(NonValidatedProperty); } + set { SetValue(NonValidatedProperty, value); } + } + + public int NonValidatedDirect + { + get { return _direct; } + set { SetAndRaise(NonValidatedDirectProperty, ref _nonValidatedDirect, value); } + } + + public int ValidatedDirect + { + get { return _direct; } + set { SetAndRaise(ValidatedDirectProperty, ref _direct, value); } + } + + public IList Notifications { get; } = new List(); + + protected override void UpdateDataValidation(AvaloniaProperty property, BindingNotification notification) + { + Notifications.Add(notification); + } + } + } +} diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs index 1d8bb80cba..cbeb8765c3 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs @@ -284,7 +284,6 @@ namespace Avalonia.Base.UnitTests Assert.Equal("newvalue", target.Foo); } - [Fact] public void UnsetValue_Is_Used_On_AddOwnered_Property() { @@ -360,7 +359,7 @@ namespace Avalonia.Base.UnitTests target.Bind(Class1.FooProperty, source); source.OnNext("initial"); - source.OnNext(new BindingError(new InvalidOperationException("Foo"))); + source.OnNext(new BindingNotification(new InvalidOperationException("Foo"), BindingErrorType.Error)); Assert.Equal("initial", target.GetValue(Class1.FooProperty)); } @@ -373,7 +372,10 @@ namespace Avalonia.Base.UnitTests target.Bind(Class1.FooProperty, source); source.OnNext("initial"); - source.OnNext(new BindingError(new InvalidOperationException("Foo"), "fallback")); + source.OnNext(new BindingNotification( + new InvalidOperationException("Foo"), + BindingErrorType.Error, + "fallback")); Assert.Equal("fallback", target.GetValue(Class1.FooProperty)); } @@ -389,7 +391,7 @@ namespace Avalonia.Base.UnitTests { if (level == LogEventLevel.Error && area == LogArea.Binding && - mt == "Error binding to {Target}.{Property}: {Message}" && + mt == "Error in binding to {Target}.{Property}: {Message}" && pv.Length == 3 && pv[0] is Class1 && object.ReferenceEquals(pv[1], Class1.FooProperty) && @@ -403,7 +405,7 @@ namespace Avalonia.Base.UnitTests { target.Bind(Class1.FooProperty, source); source.OnNext("baz"); - source.OnNext(new BindingError(new InvalidOperationException("Binding Error Message"))); + source.OnNext(new BindingNotification(new InvalidOperationException("Binding Error Message"), BindingErrorType.Error)); } Assert.True(called); diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaPropertyRegistryTests.cs b/tests/Avalonia.Base.UnitTests/AvaloniaPropertyRegistryTests.cs index 4c15f9e676..da0b0252a3 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaPropertyRegistryTests.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaPropertyRegistryTests.cs @@ -25,7 +25,7 @@ namespace Avalonia.Base.UnitTests .Select(x => x.Name) .ToArray(); - Assert.Equal(new[] { "Foo", "Baz", "Qux", "Attached", "ValidationStatus" }, names); + Assert.Equal(new[] { "Foo", "Baz", "Qux", "Attached" }, names); } [Fact] @@ -35,7 +35,7 @@ namespace Avalonia.Base.UnitTests .Select(x => x.Name) .ToArray(); - Assert.Equal(new[] { "Bar", "Flob", "Fred", "Foo", "Baz", "Qux", "Attached", "ValidationStatus" }, names); + Assert.Equal(new[] { "Bar", "Flob", "Fred", "Foo", "Baz", "Qux", "Attached" }, names); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj index ef831b27fd..e46b43d90e 100644 --- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj +++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj @@ -98,7 +98,7 @@ - + diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 5619311537..8f206cc016 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -1,8 +1,11 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; +using Avalonia.Markup.Xaml.Data; using Avalonia.Platform; using Avalonia.UnitTests; using Moq; @@ -21,6 +24,52 @@ namespace Avalonia.Controls.UnitTests } [Fact] + public void Typing_Beginning_With_0_Should_Not_Modify_Text_When_Bound_To_Int() + { + using (UnitTestApplication.Start(Services)) + { + var source = new Class1(); + var target = new TextBox + { + DataContext = source, + Template = CreateTemplate(), + }; + + target.ApplyTemplate(); + target.Bind(TextBox.TextProperty, new Binding(nameof(Class1.Foo), BindingMode.TwoWay)); + + Assert.Equal("0", target.Text); + + target.CaretIndex = 1; + target.RaiseEvent(new TextInputEventArgs + { + RoutedEvent = InputElement.TextInputEvent, + Text = "2", + }); + + Assert.Equal("02", target.Text); + } + } + + private static TestServices Services => TestServices.MockThreadingInterface.With( + standardCursorFactory: Mock.Of()); + + private IControlTemplate CreateTemplate() + { + return new FuncControlTemplate(control => + new TextPresenter + { + Name = "PART_TextPresenter", + [!!TextPresenter.TextProperty] = new Binding + { + Path = "Text", + Mode = BindingMode.TwoWay, + Priority = BindingPriority.TemplatedParent, + RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent), + }, + }); + } + public void Control_Backspace_Should_Remove_The_Word_Before_The_Caret_If_There_Is_No_Selection() { AvaloniaLocator.CurrentMutable @@ -109,5 +158,16 @@ namespace Avalonia.Controls.UnitTests Key = key }); } + + private class Class1 : NotifyingBase + { + private int _foo; + + public int Foo + { + get { return _foo; } + set { _foo = value; RaisePropertyChanged(); } + } + } } } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs similarity index 51% rename from tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs rename to tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs index 6ff3629acf..ff3634b9fe 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs @@ -5,71 +5,84 @@ using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Data; using Avalonia.Markup.Xaml.Data; +using Avalonia.Platform; using Avalonia.UnitTests; +using Moq; using Xunit; namespace Avalonia.Controls.UnitTests { - public class TextBoxTests_ValidationState + public class TextBoxTests_DataValidation { [Fact] - public void Setter_Exceptions_Should_Set_ValidationState() + public void Setter_Exceptions_Should_Set_Error_Pseudoclass() { - using (UnitTestApplication.Start(TestServices.MockThreadingInterface)) + using (UnitTestApplication.Start(Services)) { - var target = new TextBox(); - var binding = new Binding(nameof(ExceptionTest.LessThan10)); - binding.Source = new ExceptionTest(); - binding.EnableValidation = true; - target.Bind(TextBox.TextProperty, binding); + var target = new TextBox + { + DataContext = new ExceptionTest(), + [!TextBox.TextProperty] = new Binding(nameof(ExceptionTest.LessThan10), BindingMode.TwoWay), + Template = CreateTemplate(), + }; - Assert.True(target.ValidationStatus.IsValid); - target.Text = "20"; - Assert.False(target.ValidationStatus.IsValid); - target.Text = "1"; - Assert.True(target.ValidationStatus.IsValid); - } - } + target.ApplyTemplate(); - [Fact(Skip = "TODO: Not yet passing")] - public void Unconvertable_Value_Should_Set_ValidationState() - { - using (UnitTestApplication.Start(TestServices.MockThreadingInterface)) - { - var target = new TextBox(); - var binding = new Binding(nameof(ExceptionTest.LessThan10)); - binding.Source = new ExceptionTest(); - binding.EnableValidation = true; - target.Bind(TextBox.TextProperty, binding); - - Assert.True(target.ValidationStatus.IsValid); - target.Text = "foo"; - Assert.False(target.ValidationStatus.IsValid); + Assert.False(target.Classes.Contains(":error")); + target.Text = "20"; + Assert.True(target.Classes.Contains(":error")); target.Text = "1"; - Assert.True(target.ValidationStatus.IsValid); + Assert.False(target.Classes.Contains(":error")); } } [Fact] - public void Indei_Should_Set_ValidationState() + public void Setter_Exceptions_Should_Set_DataValidationErrors() { - using (UnitTestApplication.Start(TestServices.MockThreadingInterface)) + using (UnitTestApplication.Start(Services)) { - var target = new TextBox(); - var binding = new Binding(nameof(ExceptionTest.LessThan10)); - binding.Source = new IndeiTest(); - binding.EnableValidation = true; - target.Bind(TextBox.TextProperty, binding); + var target = new TextBox + { + DataContext = new ExceptionTest(), + [!TextBox.TextProperty] = new Binding(nameof(ExceptionTest.LessThan10), BindingMode.TwoWay), + Template = CreateTemplate(), + }; + + target.ApplyTemplate(); - Assert.True(target.ValidationStatus.IsValid); + Assert.Null(target.DataValidationErrors); target.Text = "20"; - Assert.False(target.ValidationStatus.IsValid); + Assert.Equal(1, target.DataValidationErrors.Count()); + Assert.IsType(target.DataValidationErrors.Single()); target.Text = "1"; - Assert.True(target.ValidationStatus.IsValid); + Assert.Null(target.DataValidationErrors); } } + private static TestServices Services => TestServices.MockThreadingInterface.With( + standardCursorFactory: Mock.Of()); + + private IControlTemplate CreateTemplate() + { + return new FuncControlTemplate(control => + new TextPresenter + { + Name = "PART_TextPresenter", + [!!TextPresenter.TextProperty] = new Binding + { + Path = "Text", + Mode = BindingMode.TwoWay, + Priority = BindingPriority.TemplatedParent, + RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent), + }, + }); + } + private class ExceptionTest { private int _lessThan10; diff --git a/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj b/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj index 587deb4864..9aef304beb 100644 --- a/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj +++ b/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj @@ -98,6 +98,7 @@ + diff --git a/tests/Avalonia.LeakTests/ExpressionObserverTests.cs b/tests/Avalonia.LeakTests/ExpressionObserverTests.cs new file mode 100644 index 0000000000..3dbc62424f --- /dev/null +++ b/tests/Avalonia.LeakTests/ExpressionObserverTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using Avalonia.Collections; +using Avalonia.Markup.Data; +using Avalonia.UnitTests; +using JetBrains.dotMemoryUnit; +using Xunit; +using Xunit.Abstractions; + +namespace Avalonia.LeakTests +{ + [DotMemoryUnit(FailIfRunWithoutSupport = false)] + public class ExpressionObserverTests + { + public ExpressionObserverTests(ITestOutputHelper atr) + { + DotMemoryUnitTestOutput.SetOutputMethod(atr.WriteLine); + } + + [Fact] + public void Should_Not_Keep_Source_Alive_ObservableCollection() + { + Func run = () => + { + var source = new { Foo = new AvaloniaList {"foo", "bar"} }; + var target = new ExpressionObserver(source, "Foo"); + + target.Subscribe(_ => { }); + return target; + }; + + var result = run(); + + dotMemory.Check(memory => + Assert.Equal(0, memory.GetObjects(where => where.Type.Is>()).ObjectsCount)); + } + + [Fact] + public void Should_Not_Keep_Source_Alive_ObservableCollection_With_DataValidation() + { + Func run = () => + { + var source = new { Foo = new AvaloniaList { "foo", "bar" } }; + var target = new ExpressionObserver(source, "Foo", true); + + target.Subscribe(_ => { }); + return target; + }; + + var result = run(); + + dotMemory.Check(memory => + Assert.Equal(0, memory.GetObjects(where => where.Type.Is>()).ObjectsCount)); + } + + [Fact] + public void Should_Not_Keep_Source_Alive_NonIntegerIndexer() + { + Func run = () => + { + var source = new { Foo = new NonIntegerIndexer() }; + var target = new ExpressionObserver(source, "Foo"); + + target.Subscribe(_ => { }); + return target; + }; + + var result = run(); + + dotMemory.Check(memory => + Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); + } + + private class NonIntegerIndexer : NotifyingBase + { + private readonly Dictionary _storage = new Dictionary(); + + public string this[string key] + { + get + { + return _storage[key]; + } + set + { + _storage[key] = value; + RaisePropertyChanged(CommonPropertyNames.IndexerName); + } + } + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj index ae691bfc0f..66d234ac19 100644 --- a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj +++ b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj @@ -10,10 +10,11 @@ Properties Avalonia.Markup.UnitTests Avalonia.Markup.UnitTests - v4.6 + v4.5 512 + true @@ -34,7 +35,7 @@ - ..\..\packages\Microsoft.Reactive.Testing.3.0.0\lib\net46\Microsoft.Reactive.Testing.dll + ..\..\packages\Microsoft.Reactive.Testing.3.0.0\lib\net45\Microsoft.Reactive.Testing.dll True @@ -43,9 +44,13 @@ True + + C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETPortable\v4.5\Profile\Profile7\System.ComponentModel.Annotations.dll + + - ..\..\packages\System.Reactive.Core.3.0.0\lib\net46\System.Reactive.Core.dll + ..\..\packages\System.Reactive.Core.3.0.0\lib\net45\System.Reactive.Core.dll True @@ -53,11 +58,15 @@ True - ..\..\packages\System.Reactive.Linq.3.0.0\lib\net46\System.Reactive.Linq.dll + ..\..\packages\System.Reactive.Linq.3.0.0\lib\net45\System.Reactive.Linq.dll True - ..\..\packages\System.Reactive.PlatformServices.3.0.0\lib\net46\System.Reactive.PlatformServices.dll + ..\..\packages\System.Reactive.PlatformServices.3.0.0\lib\net45\System.Reactive.PlatformServices.dll + True + + + ..\..\packages\System.Reactive.Windows.Threading.3.0.0\lib\net45\System.Reactive.Windows.Threading.dll True @@ -66,6 +75,7 @@ + ..\..\packages\xunit.abstractions.2.0.0\lib\net35\xunit.abstractions.dll True @@ -85,7 +95,10 @@ - + + + + @@ -97,9 +110,8 @@ - - - + + diff --git a/tests/Avalonia.Markup.UnitTests/Data/BindingExpressionTests.cs b/tests/Avalonia.Markup.UnitTests/Data/BindingExpressionTests.cs new file mode 100644 index 0000000000..c53dc417b0 --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/BindingExpressionTests.cs @@ -0,0 +1,320 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reactive.Linq; +using System.Threading; +using Avalonia.Data; +using Avalonia.Markup.Data; +using Avalonia.UnitTests; +using Moq; +using Xunit; + +namespace Avalonia.Markup.UnitTests.Data +{ + public class BindingExpressionTests + { + [Fact] + public async void Should_Get_Simple_Property_Value() + { + var data = new Class1 { StringValue = "foo" }; + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(string)); + var result = await target.Take(1); + + Assert.Equal("foo", result); + } + + [Fact] + public void Should_Set_Simple_Property_Value() + { + var data = new Class1 { StringValue = "foo" }; + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(string)); + + target.OnNext("bar"); + + Assert.Equal("bar", data.StringValue); + } + + [Fact] + public async void Should_Convert_Get_String_To_Double() + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + + var data = new Class1 { StringValue = "5.6" }; + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double)); + var result = await target.Take(1); + + Assert.Equal(5.6, result); + } + + [Fact] + public async void Getting_Invalid_Double_String_Should_Return_BindingError() + { + var data = new Class1 { StringValue = "foo" }; + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double)); + var result = await target.Take(1); + + Assert.IsType(result); + } + + [Fact] + public async void Should_Coerce_Get_Null_Double_String_To_UnsetValue() + { + var data = new Class1 { StringValue = null }; + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double)); + var result = await target.Take(1); + + Assert.Equal(AvaloniaProperty.UnsetValue, result); + } + + [Fact] + public void Should_Convert_Set_String_To_Double() + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + + var data = new Class1 { StringValue = (5.6).ToString() }; + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double)); + + target.OnNext(6.7); + + Assert.Equal((6.7).ToString(), data.StringValue); + } + + [Fact] + public async void Should_Convert_Get_Double_To_String() + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + + var data = new Class1 { DoubleValue = 5.6 }; + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); + var result = await target.Take(1); + + Assert.Equal((5.6).ToString(), result); + } + + [Fact] + public void Should_Convert_Set_Double_To_String() + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + + var data = new Class1 { DoubleValue = 5.6 }; + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); + + target.OnNext("6.7"); + + Assert.Equal(6.7, data.DoubleValue); + } + + [Fact] + public async void Should_Return_BindingNotification_With_FallbackValue_For_NonConvertibe_Target_Value() + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + + var data = new Class1 { StringValue = "foo" }; + var target = new BindingExpression( + new ExpressionObserver(data, "StringValue"), + typeof(int), + 42, + DefaultValueConverter.Instance); + var result = await target.Take(1); + + Assert.Equal( + new BindingNotification( + new InvalidCastException("'foo' is not a valid number."), + BindingErrorType.Error, + 42), + result); + } + + [Fact] + public async void Should_Return_BindingNotification_With_FallbackValue_For_NonConvertibe_Target_Value_With_Data_Validation() + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + + var data = new Class1 { StringValue = "foo" }; + var target = new BindingExpression( + new ExpressionObserver(data, "StringValue", true), + typeof(int), + 42, + DefaultValueConverter.Instance); + var result = await target.Take(1); + + Assert.Equal( + new BindingNotification( + new InvalidCastException("'foo' is not a valid number."), + BindingErrorType.Error, + 42), + result); + } + + [Fact] + public async void Should_Return_BindingNotification_For_Invalid_FallbackValue() + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + + var data = new Class1 { StringValue = "foo" }; + var target = new BindingExpression( + new ExpressionObserver(data, "StringValue"), + typeof(int), + "bar", + DefaultValueConverter.Instance); + var result = await target.Take(1); + + Assert.Equal( + new BindingNotification( + new AggregateException( + new InvalidCastException("Could not convert 'foo' to 'System.Int32'"), + new InvalidCastException("Could not convert FallbackValue 'bar' to 'System.Int32'")), + BindingErrorType.Error), + result); + } + + [Fact] + public async void Should_Return_BindingNotification_For_Invalid_FallbackValue_With_Data_Validation() + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + + var data = new Class1 { StringValue = "foo" }; + var target = new BindingExpression( + new ExpressionObserver(data, "StringValue", true), + typeof(int), + "bar", + DefaultValueConverter.Instance); + var result = await target.Take(1); + + Assert.Equal( + new BindingNotification( + new AggregateException( + new InvalidCastException("Could not convert 'foo' to 'System.Int32'"), + new InvalidCastException("Could not convert FallbackValue 'bar' to 'System.Int32'")), + BindingErrorType.Error), + result); + } + + [Fact] + public void Setting_Invalid_Double_String_Should_Not_Change_Target() + { + var data = new Class1 { DoubleValue = 5.6 }; + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); + + target.OnNext("foo"); + + Assert.Equal(5.6, data.DoubleValue); + } + + [Fact] + public void Setting_Invalid_Double_String_Should_Use_FallbackValue() + { + var data = new Class1 { DoubleValue = 5.6 }; + var target = new BindingExpression( + new ExpressionObserver(data, "DoubleValue"), + typeof(string), + "9.8", + DefaultValueConverter.Instance); + + target.OnNext("foo"); + + Assert.Equal(9.8, data.DoubleValue); + } + + [Fact] + public void Should_Coerce_Setting_Null_Double_To_Default_Value() + { + var data = new Class1 { DoubleValue = 5.6 }; + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); + + target.OnNext(null); + + Assert.Equal(0, data.DoubleValue); + } + + [Fact] + public void Should_Coerce_Setting_UnsetValue_Double_To_Default_Value() + { + var data = new Class1 { DoubleValue = 5.6 }; + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); + + target.OnNext(AvaloniaProperty.UnsetValue); + + Assert.Equal(0, data.DoubleValue); + } + + [Fact] + public void Should_Pass_ConverterParameter_To_Convert() + { + var data = new Class1 { DoubleValue = 5.6 }; + var converter = new Mock(); + var target = new BindingExpression( + new ExpressionObserver(data, "DoubleValue"), + typeof(string), + converter.Object, + converterParameter: "foo"); + + target.Subscribe(_ => { }); + + converter.Verify(x => x.Convert(5.6, typeof(string), "foo", CultureInfo.CurrentUICulture)); + } + + [Fact] + public void Should_Pass_ConverterParameter_To_ConvertBack() + { + var data = new Class1 { DoubleValue = 5.6 }; + var converter = new Mock(); + var target = new BindingExpression( + new ExpressionObserver(data, "DoubleValue"), + typeof(string), + converter.Object, + converterParameter: "foo"); + + target.OnNext("bar"); + + converter.Verify(x => x.ConvertBack("bar", typeof(double), "foo", CultureInfo.CurrentUICulture)); + } + + [Fact] + public void Should_Handle_DataValidation() + { + var data = new Class1 { DoubleValue = 5.6 }; + var converter = new Mock(); + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue", true), typeof(string)); + var result = new List(); + + target.Subscribe(x => result.Add(x)); + target.OnNext(1.2); + target.OnNext("3.4"); + target.OnNext("bar"); + + Assert.Equal( + new[] + { + new BindingNotification("5.6"), + new BindingNotification("1.2"), + new BindingNotification("3.4"), + new BindingNotification( + new InvalidCastException("'bar' is not a valid number."), + BindingErrorType.Error) + }, + result); + } + + private class Class1 : NotifyingBase + { + private string _stringValue; + private double _doubleValue; + + public string StringValue + { + get { return _stringValue; } + set { _stringValue = value; RaisePropertyChanged(); } + } + + public double DoubleValue + { + get { return _doubleValue; } + set { _doubleValue = value; RaisePropertyChanged(); } + } + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExceptionValidatorTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExceptionValidatorTests.cs deleted file mode 100644 index 6ff336c5a6..0000000000 --- a/tests/Avalonia.Markup.UnitTests/Data/ExceptionValidatorTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.ComponentModel; -using System.Runtime.CompilerServices; -using Avalonia.Data; -using Avalonia.Markup.Data.Plugins; -using Xunit; - -namespace Avalonia.Markup.UnitTests.Data -{ - public class ExceptionValidatorTests - { - public class Data : INotifyPropertyChanged - { - private int nonValidated; - - public int NonValidated - { - get { return nonValidated; } - set { nonValidated = value; NotifyPropertyChanged(); } - } - - private int mustBePositive; - - public int MustBePositive - { - get { return mustBePositive; } - set - { - if (value <= 0) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } - mustBePositive = value; - } - } - - public event PropertyChangedEventHandler PropertyChanged; - - private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } - - [Fact] - public void Setting_Non_Validating_Triggers_Validation() - { - var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); - var validatorPlugin = new ExceptionValidationPlugin(); - var data = new Data(); - var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), _ => { }); - IValidationStatus status = null; - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), accessor, s => status = s); - - validator.SetValue(5, BindingPriority.LocalValue); - - Assert.NotNull(status); - } - - [Fact] - public void Setting_Validating_Property_To_Valid_Value_Returns_Successful_ValidationStatus() - { - var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); - var validatorPlugin = new ExceptionValidationPlugin(); - var data = new Data(); - var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), _ => { }); - IValidationStatus status = null; - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor, s => status = s); - - validator.SetValue(5, BindingPriority.LocalValue); - - Assert.True(status.IsValid); - } - - [Fact] - public void Setting_Validating_Property_To_Invalid_Value_Returns_Failed_ValidationStatus() - { - var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); - var validatorPlugin = new ExceptionValidationPlugin(); - var data = new Data(); - var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), _ => { }); - IValidationStatus status = null; - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor, s => status = s); - - validator.SetValue(-5, BindingPriority.LocalValue); - - Assert.False(status.IsValid); - } - } -} diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs new file mode 100644 index 0000000000..fb98144647 --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -0,0 +1,222 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using Avalonia.Data; +using Avalonia.Markup.Data; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Markup.UnitTests.Data +{ + public class ExpressionObserverTests_DataValidation + { + [Fact] + public void Doesnt_Send_DataValidationError_When_DataValidatation_Not_Enabled() + { + var data = new ExceptionTest { MustBePositive = 5 }; + var observer = new ExpressionObserver(data, nameof(data.MustBePositive), false); + var validationMessageFound = false; + + observer.OfType() + .Where(x => x.ErrorType == BindingErrorType.DataValidationError) + .Subscribe(_ => validationMessageFound = true); + observer.SetValue(-5); + + Assert.False(validationMessageFound); + } + + [Fact] + public void Exception_Validation_Sends_DataValidationError() + { + var data = new ExceptionTest { MustBePositive = 5 }; + var observer = new ExpressionObserver(data, nameof(data.MustBePositive), true); + var validationMessageFound = false; + + observer.OfType() + .Where(x => x.ErrorType == BindingErrorType.DataValidationError) + .Subscribe(_ => validationMessageFound = true); + observer.SetValue(-5); + + Assert.True(validationMessageFound); + } + + [Fact] + public void Indei_Validation_Does_Not_Subscribe_When_DataValidatation_Not_Enabled() + { + var data = new IndeiTest { MustBePositive = 5 }; + var observer = new ExpressionObserver(data, nameof(data.MustBePositive), false); + + observer.Subscribe(_ => { }); + + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); + } + + [Fact] + public void Enabled_Indei_Validation_Subscribes() + { + var data = new IndeiTest { MustBePositive = 5 }; + var observer = new ExpressionObserver(data, nameof(data.MustBePositive), true); + var sub = observer.Subscribe(_ => { }); + + Assert.Equal(1, data.ErrorsChangedSubscriptionCount); + sub.Dispose(); + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); + } + + [Fact] + public void Validation_Plugins_Send_Correct_Notifications() + { + var data = new IndeiTest(); + var observer = new ExpressionObserver(data, nameof(data.MustBePositive), true); + var result = new List(); + + observer.Subscribe(x => result.Add(x)); + observer.SetValue(5); + observer.SetValue(-5); + observer.SetValue("foo"); + observer.SetValue(5); + + Assert.Equal(new[] + { + new BindingNotification(0), + + // Value is notified twice as ErrorsChanged is always called by IndeiTest. + new BindingNotification(5), + new BindingNotification(5), + + // Value is first signalled without an error as validation hasn't been updated. + new BindingNotification(-5), + new BindingNotification(new Exception("Must be positive"), BindingErrorType.DataValidationError, -5), + + // Exception is thrown by trying to set value to "foo". + new BindingNotification( + new ArgumentException("Object of type 'System.String' cannot be converted to type 'System.Int32'."), + BindingErrorType.DataValidationError), + + // Value is set then validation is updated. + new BindingNotification(new Exception("Must be positive"), BindingErrorType.DataValidationError, 5), + new BindingNotification(5), + }, result); + } + + [Fact] + public void Doesnt_Subscribe_To_Indei_Of_Intermediate_Object_In_Chain() + { + var data = new Container + { + Inner = new IndeiTest() + }; + + var observer = new ExpressionObserver( + data, + $"{nameof(Container.Inner)}.{nameof(IndeiTest.MustBePositive)}", + true); + + observer.Subscribe(_ => { }); + + // We may want to change this but I've never seen an example of data validation on an + // intermediate object in a chain so for the moment I'm not sure what the result of + // validating such a thing should look like. + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); + Assert.Equal(1, ((IndeiTest)data.Inner).ErrorsChangedSubscriptionCount); + } + + [Fact] + public void Sends_Correct_Notifications_With_Property_Chain() + { + var container = new Container(); + var inner = new IndeiTest(); + + var observer = new ExpressionObserver( + container, + $"{nameof(Container.Inner)}.{nameof(IndeiTest.MustBePositive)}", + true); + var result = new List(); + + observer.Subscribe(x => result.Add(x)); + + Assert.Equal(new[] + { + new BindingNotification( + new MarkupBindingChainNullException("Inner.MustBePositive", "Inner"), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + }, result); + } + + public class ExceptionTest : NotifyingBase + { + private int _mustBePositive; + + public int MustBePositive + { + get { return _mustBePositive; } + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _mustBePositive = value; + RaisePropertyChanged(); + } + } + } + + private class IndeiTest : IndeiBase + { + private int _mustBePositive; + private Dictionary> _errors = new Dictionary>(); + + public int MustBePositive + { + get { return _mustBePositive; } + set + { + _mustBePositive = value; + RaisePropertyChanged(); + + if (value >= 0) + { + _errors.Remove(nameof(MustBePositive)); + RaiseErrorsChanged(nameof(MustBePositive)); + } + else + { + _errors[nameof(MustBePositive)] = new[] { "Must be positive" }; + RaiseErrorsChanged(nameof(MustBePositive)); + } + } + } + + public override bool HasErrors => _mustBePositive >= 0; + + public override IEnumerable GetErrors(string propertyName) + { + IList result; + _errors.TryGetValue(propertyName, out result); + return result; + } + } + + private class Container : IndeiBase + { + private object _inner; + + public object Inner + { + get { return _inner; } + set { _inner = value; RaisePropertyChanged(); } + } + + public override bool HasErrors => false; + public override IEnumerable GetErrors(string propertyName) => null; + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs index b79498baae..f6c4540611 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs @@ -189,58 +189,22 @@ namespace Avalonia.Markup.UnitTests.Data var expected = new[] { "bar", "bar2" }; Assert.Equal(expected, result); - Assert.Equal(0, data.Foo.SubscriptionCount); - } - - [Fact] - public void Should_Not_Keep_Source_Alive_ObservableCollection() - { - Func> run = () => - { - var source = new { Foo = new AvaloniaList { "foo", "bar" } }; - var target = new ExpressionObserver(source, "Foo"); - return Tuple.Create(target, new WeakReference(source.Foo)); - }; - - var result = run(); - result.Item1.Subscribe(x => { }); - - GC.Collect(); - - Assert.Null(result.Item2.Target); - } - - [Fact] - public void Should_Not_Keep_Source_Alive_NonIntegerIndexer() - { - Func> run = () => - { - var source = new NonIntegerIndexer(); - var target = new ExpressionObserver(source, "Foo"); - return Tuple.Create(target, new WeakReference(source)); - }; - - var result = run(); - result.Item1.Subscribe(x => { }); - - GC.Collect(); - - Assert.Null(result.Item2.Target); + Assert.Equal(0, data.Foo.PropertyChangedSubscriptionCount); } private class NonIntegerIndexer : NotifyingBase { - private Dictionary storage = new Dictionary(); + private readonly Dictionary _storage = new Dictionary(); public string this[string key] { get { - return storage[key]; + return _storage[key]; } set { - storage[key] = value; + _storage[key] = value; RaisePropertyChanged(CommonPropertyNames.IndexerName); } } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Lifetime.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Lifetime.cs index 9fa753917c..2a2bf06bf1 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Lifetime.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Lifetime.cs @@ -27,6 +27,19 @@ namespace Avalonia.Markup.UnitTests.Data Assert.True(completed); } + [Fact] + public void Should_Complete_When_Source_Observable_Errors() + { + var source = new BehaviorSubject(1); + var target = new ExpressionObserver(source, "Foo"); + var completed = false; + + target.Subscribe(_ => { }, () => completed = true); + source.OnError(new Exception()); + + Assert.True(completed); + } + [Fact] public void Should_Complete_When_Update_Observable_Completes() { @@ -40,6 +53,19 @@ namespace Avalonia.Markup.UnitTests.Data Assert.True(completed); } + [Fact] + public void Should_Complete_When_Update_Observable_Errors() + { + var update = new Subject(); + var target = new ExpressionObserver(() => 1, "Foo", update); + var completed = false; + + target.Subscribe(_ => { }, () => completed = true); + update.OnError(new Exception()); + + Assert.True(completed); + } + [Fact] public void Should_Unsubscribe_From_Source_Observable() { @@ -55,7 +81,7 @@ namespace Avalonia.Markup.UnitTests.Data scheduler.Start(); } - Assert.Equal(new[] { AvaloniaProperty.UnsetValue, "foo" }, result); + Assert.Equal(new[] { "foo" }, result); Assert.All(source.Subscriptions, x => Assert.NotEqual(Subscription.Infinite, x.Unsubscribe)); } @@ -77,22 +103,6 @@ namespace Avalonia.Markup.UnitTests.Data Assert.All(update.Subscriptions, x => Assert.NotEqual(Subscription.Infinite, x.Unsubscribe)); } - [Fact] - public void Should_Set_Node_Target_To_Null_On_Unsubscribe() - { - var target = new ExpressionObserver(new { Foo = "foo" }, "Foo"); - var result = new List(); - - using (target.Subscribe(x => result.Add(x))) - using (target.Subscribe(_ => { })) - { - Assert.NotNull(target.Node.Target); - } - - Assert.Equal(new[] { "foo" }, result); - Assert.Null(target.Node.Target); - } - private Recorded> OnNext(long time, object value) { return new Recorded>(time, Notification.CreateOnNext(value)); diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Negation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Negation.cs index b3046118be..6bee0d10f4 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Negation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Negation.cs @@ -3,6 +3,7 @@ using System; using System.Reactive.Linq; +using Avalonia.Data; using Avalonia.Markup.Data; using Xunit; @@ -61,23 +62,31 @@ namespace Avalonia.Markup.UnitTests.Data } [Fact] - public async void Should_Return_UnsetValue_For_String_Not_Convertible_To_Boolean() + public async void Should_Return_BindingNotification_For_String_Not_Convertible_To_Boolean() { var data = new { Foo = "foo" }; var target = new ExpressionObserver(data, "!Foo"); var result = await target.Take(1); - Assert.Equal(AvaloniaProperty.UnsetValue, result); + Assert.Equal( + new BindingNotification( + new InvalidCastException($"Unable to convert 'foo' to bool."), + BindingErrorType.Error), + result); } [Fact] - public async void Should_Return_Empty_For_Value_Not_Convertible_To_Boolean() + public async void Should_Return_BindingNotification_For_Value_Not_Convertible_To_Boolean() { var data = new { Foo = new object() }; var target = new ExpressionObserver(data, "!Foo"); var result = await target.Take(1); - Assert.Equal(AvaloniaProperty.UnsetValue, result); + Assert.Equal( + new BindingNotification( + new InvalidCastException($"Unable to convert 'System.Object' to bool."), + BindingErrorType.Error), + result); } [Fact] diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs index c5bb2886b5..3263aaace2 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Reactive.Linq; using System.Reactive.Subjects; +using Avalonia.Data; using Avalonia.Markup.Data; using Avalonia.UnitTests; using Xunit; @@ -27,7 +28,7 @@ namespace Avalonia.Markup.UnitTests.Data source.OnNext("bar"); sync.ExecutePostedCallbacks(); - Assert.Equal(new[] { AvaloniaProperty.UnsetValue, "foo", "bar" }, result); + Assert.Equal(new[] { "foo", "bar" }, result); } } @@ -44,10 +45,50 @@ namespace Avalonia.Markup.UnitTests.Data data.Next.OnNext(new Class2("foo")); sync.ExecutePostedCallbacks(); - Assert.Equal(new[] { AvaloniaProperty.UnsetValue, "foo" }, result); + Assert.Equal(new[] { "foo" }, result); sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); + } + } + + [Fact] + public void Should_Get_Simple_Observable_Value_With_DataValidation_Enabled() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var source = new BehaviorSubject("foo"); + var data = new { Foo = source }; + var target = new ExpressionObserver(data, "Foo", true); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + source.OnNext("bar"); + sync.ExecutePostedCallbacks(); + + // What does it mean to have data validation on an observable? Without a use-case + // it's hard to know what to do here so for the moment the value is returned. + Assert.Equal(new[] { "foo", "bar" }, result); + } + } + + [Fact] + public void Should_Get_Property_Value_From_Observable_With_DataValidation_Enabled() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var data = new Class1(); + var target = new ExpressionObserver(data, "Next.Foo", true); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + data.Next.OnNext(new Class2("foo")); + sync.ExecutePostedCallbacks(); + + Assert.Equal(new[] { new BindingNotification("foo") }, result); + + sub.Dispose(); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); } } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index 643b8fccab..aa9ee7d58b 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -32,6 +32,8 @@ namespace Avalonia.Markup.UnitTests.Data var data = new { Foo = "foo" }; var target = new ExpressionObserver(data, "Foo"); + target.Subscribe(_ => { }); + Assert.Equal(typeof(string), target.ResultType); } @@ -55,6 +57,46 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal("foo", result); } + [Fact] + public async void Should_Return_UnsetValue_For_Root_Null() + { + var data = new Class3 { Foo = "foo" }; + var target = new ExpressionObserver(default(object), "Foo"); + var result = await target.Take(1); + + Assert.Equal(AvaloniaProperty.UnsetValue, result); + } + + [Fact] + public async void Should_Return_UnsetValue_For_Root_UnsetValue() + { + var data = new Class3 { Foo = "foo" }; + var target = new ExpressionObserver(AvaloniaProperty.UnsetValue, "Foo"); + var result = await target.Take(1); + + Assert.Equal(AvaloniaProperty.UnsetValue, result); + } + + [Fact] + public async void Should_Return_UnsetValue_For_Observable_Root_Null() + { + var data = new Class3 { Foo = "foo" }; + var target = new ExpressionObserver(Observable.Return(default(object)), "Foo"); + var result = await target.Take(1); + + Assert.Equal(AvaloniaProperty.UnsetValue, result); + } + + [Fact] + public async void Should_Return_UnsetValue_For_Observable_Root_UnsetValue() + { + var data = new Class3 { Foo = "foo" }; + var target = new ExpressionObserver(Observable.Return(AvaloniaProperty.UnsetValue), "Foo"); + var result = await target.Take(1); + + Assert.Equal(AvaloniaProperty.UnsetValue, result); + } + [Fact] public async void Should_Get_Simple_Property_Chain() { @@ -71,21 +113,44 @@ namespace Avalonia.Markup.UnitTests.Data var data = new { Foo = new { Bar = new { Baz = "baz" } } }; var target = new ExpressionObserver(data, "Foo.Bar.Baz"); + target.Subscribe(_ => { }); + Assert.Equal(typeof(string), target.ResultType); } [Fact] - public async void Should_Return_BindingError_For_Broken_Chain() + public async void Should_Return_BindingNotification_Error_For_Broken_Chain() { var data = new { Foo = new { Bar = 1 } }; var target = new ExpressionObserver(data, "Foo.Bar.Baz"); var result = await target.Take(1); - Assert.IsType(result); + Assert.IsType(result); - var error = result as BindingError; - Assert.IsType(error.Exception); - Assert.Equal("Could not find CLR property 'Baz' on '1'", error.Exception.Message); + Assert.Equal( + new BindingNotification( + new MissingMemberException("Could not find CLR property 'Baz' on '1'"), BindingErrorType.Error), + result); + } + + [Fact] + public void Should_Return_BindingNotification_Error_For_Chain_With_Null_Value() + { + var data = new { Foo = default(object) }; + var target = new ExpressionObserver(data, "Foo.Bar.Baz"); + var result = new List(); + + target.Subscribe(x => result.Add(x)); + + Assert.Equal( + new[] + { + new BindingNotification( + new MarkupBindingChainNullException("Foo.Bar.Baz", "Foo"), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + }, + result); } [Fact] @@ -111,7 +176,7 @@ namespace Avalonia.Markup.UnitTests.Data sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); } [Fact] @@ -139,7 +204,7 @@ namespace Avalonia.Markup.UnitTests.Data sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); } [Fact] @@ -151,13 +216,14 @@ namespace Avalonia.Markup.UnitTests.Data var sub = target.Subscribe(x => result.Add(x)); ((Class2)data.Next).Bar = "baz"; + ((Class2)data.Next).Bar = null; - Assert.Equal(new[] { "bar", "baz" }, result); + Assert.Equal(new[] { "bar", "baz", null }, result); sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); - Assert.Equal(0, data.Next.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); + Assert.Equal(0, data.Next.PropertyChangedSubscriptionCount); } [Fact] @@ -170,39 +236,60 @@ namespace Avalonia.Markup.UnitTests.Data var sub = target.Subscribe(x => result.Add(x)); var old = data.Next; data.Next = new Class2 { Bar = "baz" }; + data.Next = new Class2 { Bar = null }; - Assert.Equal(new[] { "bar", "baz" }, result); + Assert.Equal(new[] { "bar", "baz", null }, result); sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); - Assert.Equal(0, data.Next.SubscriptionCount); - Assert.Equal(0, old.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); + Assert.Equal(0, data.Next.PropertyChangedSubscriptionCount); + Assert.Equal(0, old.PropertyChangedSubscriptionCount); } [Fact] public void Should_Track_Property_Chain_Breaking_With_Null_Then_Mending() { - var data = new Class1 { Next = new Class2 { Bar = "bar" } }; - var target = new ExpressionObserver(data, "Next.Bar"); + var data = new Class1 + { + Next = new Class2 + { + Next = new Class2 + { + Bar = "bar" + } + } + }; + + var target = new ExpressionObserver(data, "Next.Next.Bar"); var result = new List(); var sub = target.Subscribe(x => result.Add(x)); var old = data.Next; - data.Next = null; data.Next = new Class2 { Bar = "baz" }; + data.Next = old; - Assert.Equal(new[] { "bar", AvaloniaProperty.UnsetValue, "baz" }, result); + Assert.Equal( + new object[] + { + "bar", + new BindingNotification( + new MarkupBindingChainNullException("Next.Next.Bar", "Next.Next"), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + "bar" + }, + result); sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); - Assert.Equal(0, data.Next.SubscriptionCount); - Assert.Equal(0, old.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); + Assert.Equal(0, data.Next.PropertyChangedSubscriptionCount); + Assert.Equal(0, old.PropertyChangedSubscriptionCount); } [Fact] - public void Should_Track_Property_Chain_Breaking_With_Object_Then_Mending() + public void Should_Track_Property_Chain_Breaking_With_Missing_Member_Then_Mending() { var data = new Class1 { Next = new Class2 { Bar = "bar" } }; var target = new ExpressionObserver(data, "Next.Bar"); @@ -214,17 +301,23 @@ namespace Avalonia.Markup.UnitTests.Data data.Next = breaking; data.Next = new Class2 { Bar = "baz" }; - Assert.Equal(3, result.Count); - Assert.Equal("bar", result[0]); - Assert.IsType(result[1]); - Assert.Equal("baz", result[2]); + Assert.Equal( + new object[] + { + "bar", + new BindingNotification( + new MissingMemberException("Could not find CLR property 'Bar' on 'Avalonia.Markup.UnitTests.Data.ExpressionObserverTests_Property+WithoutBar'"), + BindingErrorType.Error), + "baz", + }, + result); sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); - Assert.Equal(0, data.Next.SubscriptionCount); - Assert.Equal(0, breaking.SubscriptionCount); - Assert.Equal(0, old.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); + Assert.Equal(0, data.Next.PropertyChangedSubscriptionCount); + Assert.Equal(0, breaking.PropertyChangedSubscriptionCount); + Assert.Equal(0, old.PropertyChangedSubscriptionCount); } [Fact] @@ -258,17 +351,59 @@ namespace Avalonia.Markup.UnitTests.Data scheduler.Start(); } - Assert.Equal(new[] { AvaloniaProperty.UnsetValue, "foo", "bar" }, result); + Assert.Equal(new[] { "foo", "bar" }, result); Assert.All(source.Subscriptions, x => Assert.NotEqual(Subscription.Infinite, x.Unsubscribe)); } + [Fact] + public void Subscribing_Multiple_Times_Should_Return_Values_To_All() + { + var data = new Class1 { Foo = "foo" }; + var target = new ExpressionObserver(data, "Foo"); + var result1 = new List(); + var result2 = new List(); + var result3 = new List(); + + target.Subscribe(x => result1.Add(x)); + target.Subscribe(x => result2.Add(x)); + + data.Foo = "bar"; + + target.Subscribe(x => result3.Add(x)); + + Assert.Equal(new[] { "foo", "bar" }, result1); + Assert.Equal(new[] { "foo", "bar" }, result2); + Assert.Equal(new[] { "bar" }, result3); + } + + [Fact] + public void Subscribing_Multiple_Times_Should_Only_Add_PropertyChanged_Handlers_Once() + { + var data = new Class1 { Foo = "foo" }; + var target = new ExpressionObserver(data, "Foo"); + + var sub1 = target.Subscribe(x => { }); + var sub2 = target.Subscribe(x => { }); + + Assert.Equal(1, data.PropertyChangedSubscriptionCount); + + sub1.Dispose(); + sub2.Dispose(); + + Assert.Equal(0, data.PropertyChangedSubscriptionCount); + } + [Fact] public void SetValue_Should_Set_Simple_Property_Value() { var data = new Class1 { Foo = "foo" }; var target = new ExpressionObserver(data, "Foo"); - Assert.True(target.SetValue("bar")); + using (target.Subscribe(_ => { })) + { + Assert.True(target.SetValue("bar")); + } + Assert.Equal("bar", data.Foo); } @@ -278,7 +413,11 @@ namespace Avalonia.Markup.UnitTests.Data var data = new Class1 { Next = new Class2 { Bar = "bar" } }; var target = new ExpressionObserver(data, "Next.Bar"); - Assert.True(target.SetValue("baz")); + using (target.Subscribe(_ => { })) + { + Assert.True(target.SetValue("baz")); + } + Assert.Equal("baz", ((Class2)data.Next).Bar); } @@ -288,25 +427,48 @@ namespace Avalonia.Markup.UnitTests.Data var data = new Class1 { Next = new WithoutBar()}; var target = new ExpressionObserver(data, "Next.Bar"); - Assert.False(target.SetValue("baz")); + using (target.Subscribe(_ => { })) + { + Assert.False(target.SetValue("baz")); + } } [Fact] - public void SetValue_Should_Return_False_For_Missing_Object() + public void SetValue_Should_Notify_New_Value_With_Inpc() { var data = new Class1(); - var target = new ExpressionObserver(data, "Next.Bar"); + var target = new ExpressionObserver(data, "Foo"); + var result = new List(); - Assert.False(target.SetValue("baz")); + target.Subscribe(x => result.Add(x)); + target.SetValue("bar"); + + Assert.Equal(new[] { null, "bar" }, result); } [Fact] - public async void Should_Handle_Null_Root() + public void SetValue_Should_Notify_New_Value_Without_Inpc() { - var target = new ExpressionObserver((object)null, "Foo"); - var result = await target.Take(1); + var data = new Class1(); + var target = new ExpressionObserver(data, "Bar"); + var result = new List(); - Assert.Equal(AvaloniaProperty.UnsetValue, result); + target.Subscribe(x => result.Add(x)); + target.SetValue("bar"); + + Assert.Equal(new[] { null, "bar" }, result); + } + + [Fact] + public void SetValue_Should_Return_False_For_Missing_Object() + { + var data = new Class1(); + var target = new ExpressionObserver(data, "Next.Bar"); + + using (target.Subscribe(_ => { })) + { + Assert.False(target.SetValue("baz")); + } } [Fact] @@ -325,10 +487,17 @@ namespace Avalonia.Markup.UnitTests.Data root = null; update.OnNext(Unit.Default); - Assert.Equal(new[] { "foo", "bar", AvaloniaProperty.UnsetValue }, result); - - Assert.Equal(0, first.SubscriptionCount); - Assert.Equal(0, second.SubscriptionCount); + Assert.Equal( + new object[] + { + "foo", + "bar", + AvaloniaProperty.UnsetValue, + }, + result); + + Assert.Equal(0, first.PropertyChangedSubscriptionCount); + Assert.Equal(0, second.PropertyChangedSubscriptionCount); } [Fact] @@ -351,7 +520,7 @@ namespace Avalonia.Markup.UnitTests.Data private interface INext { - int SubscriptionCount { get; } + int PropertyChangedSubscriptionCount { get; } } private class Class1 : NotifyingBase @@ -390,6 +559,7 @@ namespace Avalonia.Markup.UnitTests.Data private class Class2 : NotifyingBase, INext { private string _bar; + private INext _next; public string Bar { @@ -400,6 +570,16 @@ namespace Avalonia.Markup.UnitTests.Data RaisePropertyChanged(nameof(Bar)); } } + + public INext Next + { + get { return _next; } + set + { + _next = value; + RaisePropertyChanged(nameof(Next)); + } + } } private class Class3 : Class1 diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_SetValue.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_SetValue.cs index 4dabd34460..3238435841 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_SetValue.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_SetValue.cs @@ -18,7 +18,10 @@ namespace Avalonia.Markup.UnitTests.Data var data = new { Foo = "foo" }; var target = new ExpressionObserver(data, "Foo"); - target.SetValue("bar"); + using (target.Subscribe(_ => { })) + { + target.SetValue("bar"); + } Assert.Equal("foo", data.Foo); } @@ -29,7 +32,10 @@ namespace Avalonia.Markup.UnitTests.Data var data = new Class1 { Foo = new Class2 { Bar = "bar" } }; var target = new ExpressionObserver(data, "Foo.Bar"); - target.SetValue("foo"); + using (target.Subscribe(_ => { })) + { + target.SetValue("foo"); + } Assert.Equal("foo", data.Foo.Bar); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs index 3d4c0b1b43..3dcd8a4fbc 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; using System.Reactive.Linq; -using System.Threading; using System.Threading.Tasks; +using Avalonia.Data; using Avalonia.Markup.Data; using Avalonia.UnitTests; using Xunit; @@ -28,7 +28,7 @@ namespace Avalonia.Markup.UnitTests.Data tcs.SetResult("foo"); sync.ExecutePostedCallbacks(); - Assert.Equal(new object[] { AvaloniaProperty.UnsetValue, "foo" }, result.ToArray()); + Assert.Equal(new[] { "foo" }, result); } } @@ -43,7 +43,7 @@ namespace Avalonia.Markup.UnitTests.Data var sub = target.Subscribe(x => result.Add(x)); - Assert.Equal(new object[] { "foo" }, result.ToArray()); + Assert.Equal(new[] { "foo" }, result); } } @@ -61,10 +61,84 @@ namespace Avalonia.Markup.UnitTests.Data tcs.SetResult(new Class2("foo")); sync.ExecutePostedCallbacks(); - Assert.Equal(new object[] { AvaloniaProperty.UnsetValue, "foo" }, result.ToArray()); + Assert.Equal(new[] { "foo" }, result); } } + [Fact] + public void Should_Return_BindingNotification_Error_On_Task_Exception() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var tcs = new TaskCompletionSource(); + var data = new { Foo = tcs.Task }; + var target = new ExpressionObserver(data, "Foo"); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + tcs.SetException(new NotSupportedException()); + sync.ExecutePostedCallbacks(); + + Assert.Equal( + new[] + { + new BindingNotification( + new AggregateException(new NotSupportedException()), + BindingErrorType.Error) + }, + result); + } + } + + [Fact] + public void Should_Return_BindingNotification_Error_For_Faulted_Task() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var data = new { Foo = TaskFromException(new NotSupportedException()) }; + var target = new ExpressionObserver(data, "Foo"); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + + Assert.Equal( + new[] + { + new BindingNotification( + new AggregateException(new NotSupportedException()), + BindingErrorType.Error) + }, + result); + } + } + + [Fact] + public void Should_Get_Simple_Task_Value_With_Data_DataValidation_Enabled() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var tcs = new TaskCompletionSource(); + var data = new { Foo = tcs.Task }; + var target = new ExpressionObserver(data, "Foo", true); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + tcs.SetResult("foo"); + sync.ExecutePostedCallbacks(); + + // What does it mean to have data validation on a Task? Without a use-case it's + // hard to know what to do here so for the moment the value is returned. + Assert.Equal(new [] { "foo" }, result); + } + } + + private Task TaskFromException(Exception e) + { + var tcs = new TaskCompletionSource(); + tcs.SetException(e); + return tcs.Task; + } + private class Class1 : NotifyingBase { public Class1(Task next) diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Validation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Validation.cs deleted file mode 100644 index 59c8965cfb..0000000000 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Validation.cs +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Reactive.Linq; -using System.Runtime.CompilerServices; -using Avalonia.Data; -using Avalonia.Markup.Data; -using Xunit; - -namespace Avalonia.Markup.UnitTests.Data -{ - public class ExpressionObserverTests_Validation - { - [Fact] - public void Exception_Validation_Sends_ValidationUpdate() - { - var data = new ExceptionTest { MustBePositive = 5 }; - var observer = new ExpressionObserver(data, nameof(data.MustBePositive), false); - var validationMessageFound = false; - observer.Where(o => o is IValidationStatus).Subscribe(_ => validationMessageFound = true); - observer.SetValue(-5); - Assert.True(validationMessageFound); - } - - [Fact] - public void Disabled_Indei_Validation_Does_Not_Subscribe() - { - var data = new IndeiTest { MustBePositive = 5 }; - var observer = new ExpressionObserver(data, nameof(data.MustBePositive), false); - - observer.Subscribe(_ => { }); - - Assert.Equal(0, data.SubscriptionCount); - } - - [Fact] - public void Enabled_Indei_Validation_Subscribes() - { - var data = new IndeiTest { MustBePositive = 5 }; - var observer = new ExpressionObserver(data, nameof(data.MustBePositive), true); - var sub = observer.Subscribe(_ => { }); - - Assert.Equal(1, data.SubscriptionCount); - sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); - } - - public class ExceptionTest : INotifyPropertyChanged - { - private int _mustBePositive; - - public int MustBePositive - { - get { return _mustBePositive; } - set - { - if (value <= 0) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } - _mustBePositive = value; - } - } - - public event PropertyChangedEventHandler PropertyChanged; - - private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } - - private class IndeiTest : INotifyDataErrorInfo - { - private int _mustBePositive; - private Dictionary> _errors = new Dictionary>(); - private EventHandler _errorsChanged; - - public int MustBePositive - { - get { return _mustBePositive; } - set - { - if (value >= 0) - { - _mustBePositive = value; - _errors.Remove(nameof(MustBePositive)); - _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(MustBePositive))); - } - else - { - _errors[nameof(MustBePositive)] = new[] { "Must be positive" }; - _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(MustBePositive))); - } - } - } - - public bool HasErrors => _mustBePositive >= 0; - - public int SubscriptionCount { get; private set; } - - public event EventHandler ErrorsChanged - { - add - { - _errorsChanged += value; - ++SubscriptionCount; - } - remove - { - _errorsChanged -= value; - --SubscriptionCount; - } - } - - public IEnumerable GetErrors(string propertyName) - { - IList result; - _errors.TryGetValue(propertyName, out result); - return result; - } - } - } -} diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs deleted file mode 100644 index 0b6a507274..0000000000 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.ComponentModel; -using System.Globalization; -using System.Reactive.Linq; -using Moq; -using Avalonia.Data; -using Avalonia.Markup.Data; -using Xunit; -using System.Threading; - -namespace Avalonia.Markup.UnitTests.Data -{ - public class ExpressionSubjectTests - { - [Fact] - public async void Should_Get_Simple_Property_Value() - { - var data = new Class1 { StringValue = "foo" }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(string)); - var result = await target.Take(1); - - Assert.Equal("foo", result); - } - - [Fact] - public void Should_Set_Simple_Property_Value() - { - var data = new Class1 { StringValue = "foo" }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(string)); - - target.OnNext("bar"); - - Assert.Equal("bar", data.StringValue); - } - - [Fact] - public async void Should_Convert_Get_String_To_Double() - { - Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; - - var data = new Class1 { StringValue = "5.6" }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(double)); - var result = await target.Take(1); - - Assert.Equal(5.6, result); - } - - [Fact] - public async void Getting_Invalid_Double_String_Should_Return_BindingError() - { - var data = new Class1 { StringValue = "foo" }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(double)); - var result = await target.Take(1); - - Assert.IsType(result); - } - - [Fact] - public async void Should_Coerce_Get_Null_Double_String_To_UnsetValue() - { - var data = new Class1 { StringValue = null }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(double)); - var result = await target.Take(1); - - Assert.Equal(AvaloniaProperty.UnsetValue, result); - } - - [Fact] - public void Should_Convert_Set_String_To_Double() - { - Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; - - var data = new Class1 { StringValue = (5.6).ToString() }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(double)); - - target.OnNext(6.7); - - Assert.Equal((6.7).ToString(), data.StringValue); - } - - [Fact] - public async void Should_Convert_Get_Double_To_String() - { - Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; - - var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue"), typeof(string)); - var result = await target.Take(1); - - Assert.Equal((5.6).ToString(), result); - } - - [Fact] - public void Should_Convert_Set_Double_To_String() - { - Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; - - var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue"), typeof(string)); - - target.OnNext("6.7"); - - Assert.Equal(6.7, data.DoubleValue); - } - - [Fact] - public void Setting_Invalid_Double_String_Should_Not_Change_Target() - { - var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue"), typeof(string)); - - target.OnNext("foo"); - - Assert.Equal(5.6, data.DoubleValue); - } - - [Fact] - public void Setting_Invalid_Double_String_Should_Use_FallbackValue() - { - var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject( - new ExpressionObserver(data, "DoubleValue"), - typeof(string), - "9.8", - DefaultValueConverter.Instance); - - target.OnNext("foo"); - - Assert.Equal(9.8, data.DoubleValue); - } - - [Fact] - public void Should_Coerce_Setting_Null_Double_To_Default_Value() - { - var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue"), typeof(string)); - - target.OnNext(null); - - Assert.Equal(0, data.DoubleValue); - } - - [Fact] - public void Should_Coerce_Setting_UnsetValue_Double_To_Default_Value() - { - var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue"), typeof(string)); - - target.OnNext(AvaloniaProperty.UnsetValue); - - Assert.Equal(0, data.DoubleValue); - } - - [Fact] - public void Should_Pass_ConverterParameter_To_Convert() - { - var data = new Class1 { DoubleValue = 5.6 }; - var converter = new Mock(); - var target = new ExpressionSubject( - new ExpressionObserver(data, "DoubleValue"), - typeof(string), - converter.Object, - converterParameter: "foo"); - - target.Subscribe(_ => { }); - - converter.Verify(x => x.Convert(5.6, typeof(string), "foo", CultureInfo.CurrentUICulture)); - } - - [Fact] - public void Should_Pass_ConverterParameter_To_ConvertBack() - { - var data = new Class1 { DoubleValue = 5.6 }; - var converter = new Mock(); - var target = new ExpressionSubject( - new ExpressionObserver(data, "DoubleValue"), - typeof(string), - converter.Object, - converterParameter: "foo"); - - target.OnNext("bar"); - - converter.Verify(x => x.ConvertBack("bar", typeof(double), "foo", CultureInfo.CurrentUICulture)); - } - - private class Class1 : INotifyPropertyChanged - { - public event PropertyChangedEventHandler PropertyChanged; - - public string StringValue { get; set; } - - public double DoubleValue { get; set; } - } - } -} diff --git a/tests/Avalonia.Markup.UnitTests/Data/IndeiBase.cs b/tests/Avalonia.Markup.UnitTests/Data/IndeiBase.cs new file mode 100644 index 0000000000..bd0ab71626 --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/IndeiBase.cs @@ -0,0 +1,32 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Avalonia.UnitTests; + +namespace Avalonia.Markup.UnitTests.Data +{ + internal abstract class IndeiBase : NotifyingBase, INotifyDataErrorInfo + { + private EventHandler _errorsChanged; + + public abstract bool HasErrors { get; } + public int ErrorsChangedSubscriptionCount { get; private set; } + + public event EventHandler ErrorsChanged + { + add { _errorsChanged += value; ++ErrorsChangedSubscriptionCount; } + remove { _errorsChanged -= value; --ErrorsChangedSubscriptionCount; } + } + + public abstract IEnumerable GetErrors(string propertyName); + + protected void RaiseErrorsChanged([CallerMemberName] string propertyName = "") + { + _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs b/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs deleted file mode 100644 index 20bf164360..0000000000 --- a/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections; -using System.ComponentModel; -using System.Runtime.CompilerServices; -using Avalonia.Data; -using Avalonia.Markup.Data.Plugins; -using Xunit; - -namespace Avalonia.Markup.UnitTests.Data -{ - public class IndeiValidatorTests - { - public class Data : INotifyPropertyChanged, INotifyDataErrorInfo - { - private int nonValidated; - - public int NonValidated - { - get { return nonValidated; } - set { nonValidated = value; NotifyPropertyChanged(); } - } - - private int mustBePositive; - - public int MustBePositive - { - get { return mustBePositive; } - set - { - mustBePositive = value; - NotifyErrorsChanged(); - } - } - - public bool HasErrors - { - get - { - return MustBePositive > 0; - } - } - - public event PropertyChangedEventHandler PropertyChanged; - public event EventHandler ErrorsChanged; - - private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - - private void NotifyErrorsChanged([CallerMemberName] string propertyName = "") - { - ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); - } - - public IEnumerable GetErrors(string propertyName) - { - if (propertyName == nameof(MustBePositive) && MustBePositive <= 0) - { - yield return $"{nameof(MustBePositive)} must be positive"; - } - } - } - - [Fact] - public void Setting_Non_Validating_Does_Not_Trigger_Validation() - { - var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); - var validatorPlugin = new IndeiValidationPlugin(); - var data = new Data(); - var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), _ => { }); - IValidationStatus status = null; - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), accessor, s => status = s); - - validator.SetValue(5, BindingPriority.LocalValue); - - Assert.Null(status); - } - - [Fact] - public void Setting_Validating_Property_To_Valid_Value_Returns_Successful_ValidationStatus() - { - var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); - var validatorPlugin = new IndeiValidationPlugin(); - var data = new Data(); - var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), _ => { }); - IValidationStatus status = null; - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor, s => status = s); - - validator.SetValue(5, BindingPriority.LocalValue); - - Assert.True(status.IsValid); - } - - [Fact] - public void Setting_Validating_Property_To_Invalid_Value_Returns_Failed_ValidationStatus() - { - var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); - var validatorPlugin = new IndeiValidationPlugin(); - var data = new Data(); - var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), _ => { }); - IValidationStatus status = null; - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor, s => status = s); - - validator.SetValue(-5, BindingPriority.LocalValue); - - Assert.False(status.IsValid); - } - } -} diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/DataAnnotationsValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/DataAnnotationsValidationPluginTests.cs new file mode 100644 index 0000000000..b873971e7f --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/DataAnnotationsValidationPluginTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Avalonia.Data; +using Avalonia.Markup.Data.Plugins; +using Xunit; + +namespace Avalonia.Markup.UnitTests.Data.Plugins +{ + public class DataAnnotationsValidationPluginTests + { + [Fact] + public void Should_Match_Property_With_ValidatorAttribute() + { + var target = new DataAnnotationsValidationPlugin(); + var data = new Data(); + + Assert.True(target.Match(new WeakReference(data), nameof(Data.Between5And10))); + } + + [Fact] + public void Should_Match_Property_With_Multiple_ValidatorAttributes() + { + var target = new DataAnnotationsValidationPlugin(); + var data = new Data(); + + Assert.True(target.Match(new WeakReference(data), nameof(Data.PhoneNumber))); + } + + [Fact] + public void Should_Not_Match_Property_Without_ValidatorAttribute() + { + var target = new DataAnnotationsValidationPlugin(); + var data = new Data(); + + Assert.False(target.Match(new WeakReference(data), nameof(Data.Unvalidated))); + } + + [Fact] + public void Produces_Range_BindingNotificationsx() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new DataAnnotationsValidationPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.Between5And10)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.Between5And10), accessor); + var result = new List(); + + validator.Subscribe(x => result.Add(x)); + validator.SetValue(3, BindingPriority.LocalValue); + validator.SetValue(7, BindingPriority.LocalValue); + validator.SetValue(11, BindingPriority.LocalValue); + + Assert.Equal(new[] + { + new BindingNotification(5), + new BindingNotification( + new ValidationException("The field Between5And10 must be between 5 and 10."), + BindingErrorType.DataValidationError, + 3), + new BindingNotification(7), + new BindingNotification( + new ValidationException("The field Between5And10 must be between 5 and 10."), + BindingErrorType.DataValidationError, + 11), + }, result); + } + + [Fact] + public void Produces_Aggregate_BindingNotificationsx() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new DataAnnotationsValidationPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.PhoneNumber)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.PhoneNumber), accessor); + var result = new List(); + + validator.Subscribe(x => result.Add(x)); + validator.SetValue("123456", BindingPriority.LocalValue); + validator.SetValue("abcdefghijklm", BindingPriority.LocalValue); + + Assert.Equal(new[] + { + new BindingNotification(null), + new BindingNotification("123456"), + new BindingNotification( + new AggregateException( + new ValidationException("The PhoneNumber field is not a valid phone number."), + new ValidationException("The field PhoneNumber must be a string or array type with a maximum length of '10'.")), + BindingErrorType.DataValidationError, + "abcdefghijklm"), + }, result); + } + + private class Data + { + [Range(5, 10)] + public int Between5And10 { get; set; } = 5; + + public int Unvalidated { get; set; } + + [Phone] + [MaxLength(10)] + public string PhoneNumber { get; set; } + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs new file mode 100644 index 0000000000..4a34791008 --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using Avalonia.Data; +using Avalonia.Markup.Data.Plugins; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Markup.UnitTests.Data.Plugins +{ + public class ExceptionValidationPluginTests + { + [Fact] + public void Produces_BindingNotifications() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new ExceptionValidationPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor); + var result = new List(); + + validator.Subscribe(x => result.Add(x)); + validator.SetValue(5, BindingPriority.LocalValue); + validator.SetValue(-2, BindingPriority.LocalValue); + validator.SetValue(6, BindingPriority.LocalValue); + + Assert.Equal(new[] + { + new BindingNotification(0), + new BindingNotification(5), + new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), + new BindingNotification(6), + }, result); + } + + public class Data : NotifyingBase + { + private int _mustBePositive; + + public int MustBePositive + { + get { return _mustBePositive; } + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + if (value != _mustBePositive) + { + _mustBePositive = value; + RaisePropertyChanged(); + } + } + } + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs new file mode 100644 index 0000000000..788bc25a34 --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reactive.Linq; +using Avalonia.Data; +using Avalonia.Markup.Data.Plugins; +using Xunit; + +namespace Avalonia.Markup.UnitTests.Data.Plugins +{ + public class IndeiValidationPluginTests + { + [Fact] + public void Produces_BindingNotifications() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new IndeiValidationPlugin(); + var data = new Data { Maximum = 5 }; + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.Value)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.Value), accessor); + var result = new List(); + + validator.Subscribe(x => result.Add(x)); + validator.SetValue(5, BindingPriority.LocalValue); + validator.SetValue(6, BindingPriority.LocalValue); + data.Maximum = 10; + data.Maximum = 5; + + Assert.Equal(new[] + { + new BindingNotification(0), + new BindingNotification(5), + + // Value is first signalled without an error as validation hasn't been updated. + new BindingNotification(6), + + // Then the ErrorsChanged event is fired. + new BindingNotification(new Exception("Must be less than Maximum"), BindingErrorType.DataValidationError, 6), + + // Maximum is changed to 10 so value is now valid. + new BindingNotification(6), + + // And Maximum is changed back to 5. + new BindingNotification(new Exception("Must be less than Maximum"), BindingErrorType.DataValidationError, 6), + }, result); + } + + [Fact] + public void Subscribes_And_Unsubscribes() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new IndeiValidationPlugin(); + var data = new Data { Maximum = 5 }; + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.Value)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.Value), accessor); + + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); + var sub = validator.Subscribe(_ => { }); + Assert.Equal(1, data.ErrorsChangedSubscriptionCount); + sub.Dispose(); + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); + } + + internal class Data : IndeiBase + { + private int _value; + private int _maximum; + private string _error; + + public override bool HasErrors => _error != null; + + public int Value + { + get { return _value; } + set + { + _value = value; + RaisePropertyChanged(); + UpdateError(); + } + } + + public int Maximum + { + get { return _maximum; } + set + { + _maximum = value; + UpdateError(); + } + } + + public override IEnumerable GetErrors(string propertyName) + { + if (propertyName == nameof(Value) && _error != null) + { + return new[] { _error }; + } + + return null; + } + + private void UpdateError() + { + if (_value <= _maximum) + { + if (_error != null) + { + _error = null; + RaiseErrorsChanged(nameof(Value)); + } + } + else + { + if (_error == null) + { + _error = "Must be less than Maximum"; + RaiseErrorsChanged(nameof(Value)); + } + } + } + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/DefaultValueConverterTests.cs b/tests/Avalonia.Markup.UnitTests/DefaultValueConverterTests.cs index 2e52dff087..fd28f2e900 100644 --- a/tests/Avalonia.Markup.UnitTests/DefaultValueConverterTests.cs +++ b/tests/Avalonia.Markup.UnitTests/DefaultValueConverterTests.cs @@ -115,7 +115,7 @@ namespace Avalonia.Markup.UnitTests null, CultureInfo.InvariantCulture); - Assert.IsType(result); + Assert.IsType(result); } private enum TestEnum diff --git a/tests/Avalonia.Markup.UnitTests/app.config b/tests/Avalonia.Markup.UnitTests/app.config index fa66e8c206..654f911514 100644 --- a/tests/Avalonia.Markup.UnitTests/app.config +++ b/tests/Avalonia.Markup.UnitTests/app.config @@ -1,11 +1,11 @@ - + - - + + - \ No newline at end of file + diff --git a/tests/Avalonia.Markup.UnitTests/packages.config b/tests/Avalonia.Markup.UnitTests/packages.config index 34563ef392..d264c076fd 100644 --- a/tests/Avalonia.Markup.UnitTests/packages.config +++ b/tests/Avalonia.Markup.UnitTests/packages.config @@ -1,11 +1,14 @@  - + - - - - + + + + + + + diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj index 0e2e11c300..54e61fc7d2 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj @@ -93,9 +93,9 @@ + - diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs index 7d8528c5d7..210ad2ab0b 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Markup.Data; @@ -164,17 +166,23 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data [Fact] public void DataContext_Binding_Should_Produce_Correct_Results() { + var viewModel = new { Foo = "bar" }; var root = new Decorator { - DataContext = new { Foo = "bar" }, + DataContext = viewModel, }; var child = new Control(); - var dataContextBinding = new Binding("Foo"); var values = new List(); - child.GetObservable(Border.DataContextProperty).Subscribe(x => values.Add(x)); - child.Bind(ContentControl.DataContextProperty, dataContextBinding); + child.GetObservable(Control.DataContextProperty).Subscribe(x => values.Add(x)); + child.Bind(Control.DataContextProperty, new Binding("Foo")); + + // When binding to DataContext and the target isn't found, the binding should produce + // null rather than UnsetValue in order to not propagate incorrect DataContexts from + // parent controls while things are being set up. This logic is implemented in + // `Avalonia.Markup.Xaml.Binding.Initiate`. + Assert.True(child.IsSet(Control.DataContextProperty)); root.Child = child; @@ -192,7 +200,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data var result = binding.Initiate(target, TextBox.TextProperty).Subject; - Assert.IsType(((ExpressionSubject)result).Converter); + Assert.IsType(((BindingExpression)result).Converter); } [Fact] @@ -208,7 +216,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data var result = binding.Initiate(target, TextBox.TextProperty).Subject; - Assert.Same(converter.Object, ((ExpressionSubject)result).Converter); + Assert.Same(converter.Object, ((BindingExpression)result).Converter); } [Fact] @@ -225,7 +233,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data var result = binding.Initiate(target, TextBox.TextProperty).Subject; - Assert.Same("foo", ((ExpressionSubject)result).ConverterParameter); + Assert.Same("foo", ((BindingExpression)result).ConverterParameter); } [Fact] @@ -267,7 +275,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data /// /// /// - Items is bound to DataContext first, followed by say SelectedIndex - /// - When the ListBox is removed from the visual tree, DataContext becomes null (as it's + /// - When the ListBox is removed from the logical tree, DataContext becomes null (as it's /// inherited) /// - This changes Items to null, which changes SelectedIndex to null as there are no /// longer any items @@ -294,12 +302,12 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data // Bind Foo and Bar to the VM. target.Bind(OldDataContextTest.FooProperty, fooBinding); - target.Bind(OldDataContextTest.BarProperty, barBinding); + //target.Bind(OldDataContextTest.BarProperty, barBinding); target.DataContext = vm; // Make sure the control's Foo and Bar properties are read from the VM Assert.Equal(1, target.GetValue(OldDataContextTest.FooProperty)); - Assert.Equal(2, target.GetValue(OldDataContextTest.BarProperty)); + //Assert.Equal(2, target.GetValue(OldDataContextTest.BarProperty)); // Set DataContext to null. target.DataContext = null; diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs new file mode 100644 index 0000000000..5dd8d0cdf9 --- /dev/null +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs @@ -0,0 +1,75 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Reactive.Linq; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Markup.Data; +using Avalonia.Markup.Xaml.Data; +using Xunit; + +namespace Avalonia.Markup.Xaml.UnitTests.Data +{ + public class BindingTests_DataValidation + { + [Fact] + public void Initiate_Should_Not_Enable_Data_Validation_With_BindingPriority_LocalValue() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)); + var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: false); + var subject = (BindingExpression)instanced.Subject; + object result = null; + + subject.Subscribe(x => result = x); + + Assert.IsType(result); + } + + [Fact] + public void Initiate_Should_Enable_Data_Validation_With_BindingPriority_LocalValue() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)); + var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: true); + var subject = (BindingExpression)instanced.Subject; + object result = null; + + subject.Subscribe(x => result = x); + + Assert.Equal(new BindingNotification("foo"), result); + } + + [Fact] + public void Initiate_Should_Not_Enable_Data_Validation_With_BindingPriority_TemplatedParent() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) { Priority = BindingPriority.TemplatedParent }; + var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: true); + var subject = (BindingExpression)instanced.Subject; + object result = null; + + subject.Subscribe(x => result = x); + + Assert.IsType(result); + } + + private class Class1 + { + public string Foo { get; set; } = "foo"; + } + } +} diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs index 0fed786f07..8759cb42c5 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -1,7 +1,11 @@ using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Markup.Xaml.Data; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Markup.Xaml.UnitTests.Data @@ -9,143 +13,112 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data public class BindingTests_Validation { [Fact] - public void Disabled_Validation_Should_Trigger_Validation_Change_On_Exception() + public void Non_Validated_Property_Does_Not_Receive_BindingNotifications() { var source = new ValidationTestModel { MustBePositive = 5 }; - var target = new TestControl { DataContext = source }; - var binding = new Binding + var target = new TestControl { - Path = nameof(source.MustBePositive), - Mode = BindingMode.TwoWay, - - // Even though EnableValidation = false, exception validation is enabled. - EnableValidation = false, + DataContext = source, + [!TestControl.NonValidatedProperty] = new Binding(nameof(source.MustBePositive)), }; - target.Bind(TestControl.ValidationTestProperty, binding); - - target.ValidationTest = -5; - - Assert.False(target.ValidationStatus.IsValid); + Assert.Empty(target.Notifications); } [Fact] - public void Enabled_Validation_Should_Trigger_Validation_Change_On_Exception() + public void Validated_Direct_Property_Receives_BindingNotifications() { var source = new ValidationTestModel { MustBePositive = 5 }; - var target = new TestControl { DataContext = source }; - var binding = new Binding + var target = new TestControl { - Path = nameof(source.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, + DataContext = source, }; - target.Bind(TestControl.ValidationTestProperty, binding); - - target.ValidationTest = -5; - Assert.False(target.ValidationStatus.IsValid); - } - + target.Bind( + TestControl.ValidatedDirectProperty, + new Binding(nameof(source.MustBePositive), BindingMode.TwoWay)); - [Fact] - public void Passed_Validation_Should_Not_Add_Invalid_Pseudo_Class() - { - var control = new TestControl(); - var model = new ValidationTestModel { MustBePositive = 1 }; - var binding = new Binding - { - Path = nameof(model.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, - }; + target.ValidatedDirect = 6; + target.ValidatedDirect = -1; + target.ValidatedDirect = 7; - control.Bind(TestControl.ValidationTestProperty, binding); - control.DataContext = model; - Assert.DoesNotContain(control.Classes, x => x == ":invalid"); + Assert.Equal( + new[] + { + new BindingNotification(5), + new BindingNotification(6), + new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), + new BindingNotification(7), + }, + target.Notifications.AsEnumerable()); } - [Fact] - public void Failed_Validation_Should_Add_Invalid_Pseudo_Class() + private class TestControl : Control { - var control = new TestControl(); - var model = new ValidationTestModel { MustBePositive = 1 }; - var binding = new Binding + public static readonly StyledProperty NonValidatedProperty = + AvaloniaProperty.Register( + nameof(Validated), + enableDataValidation: false); + + public static readonly StyledProperty ValidatedProperty = + AvaloniaProperty.Register( + nameof(Validated), + enableDataValidation: true); + + public static readonly DirectProperty ValidatedDirectProperty = + AvaloniaProperty.RegisterDirect( + nameof(Validated), + o => o.ValidatedDirect, + (o, v) => o.ValidatedDirect = v, + enableDataValidation: true); + + private int _direct; + + public int NonValidated { - Path = nameof(model.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, - }; - - control.Bind(TestControl.ValidationTestProperty, binding); - control.DataContext = model; - control.ValidationTest = -5; - Assert.Contains(control.Classes, x => x == ":invalid"); - } - - [Fact] - public void Failed_Then_Passed_Validation_Should_Remove_Invalid_Pseudo_Class() - { - var control = new TestControl(); - var model = new ValidationTestModel { MustBePositive = 1 }; + get { return GetValue(NonValidatedProperty); } + set { SetValue(NonValidatedProperty, value); } + } - var binding = new Binding + public int Validated { - Path = nameof(model.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, - }; - - control.Bind(TestControl.ValidationTestProperty, binding); - control.DataContext = model; - - - control.ValidationTest = -5; - Assert.Contains(control.Classes, x => x == ":invalid"); - control.ValidationTest = 5; - Assert.DoesNotContain(control.Classes, x => x == ":invalid"); - } - - private class TestControl : Control - { - public static readonly StyledProperty ValidationTestProperty - = AvaloniaProperty.Register(nameof(ValidationTest), 1, defaultBindingMode: BindingMode.TwoWay); + get { return GetValue(ValidatedProperty); } + set { SetValue(ValidatedProperty, value); } + } - public int ValidationTest + public int ValidatedDirect { - get - { - return GetValue(ValidationTestProperty); - } - set - { - SetValue(ValidationTestProperty, value); - } + get { return _direct; } + set { SetAndRaise(ValidatedDirectProperty, ref _direct, value); } } - protected override void DataValidationChanged(AvaloniaProperty property, IValidationStatus status) + public IList Notifications { get; } = new List(); + + protected override void BindingNotificationReceived(AvaloniaProperty property, BindingNotification notification) { - if (property == ValidationTestProperty) - { - UpdateValidationState(status); - } + Notifications.Add(notification); } } - private class ValidationTestModel + private class ValidationTestModel : NotifyingBase { - private int mustBePositive; + private int _mustBePositive; public int MustBePositive { - get { return mustBePositive; } + get { return _mustBePositive; } set { if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value)); } - mustBePositive = value; + + if (_mustBePositive != value) + { + _mustBePositive = value; + RaisePropertyChanged(); + } } } } diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs index df62a1ed41..0c2151850f 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs @@ -39,7 +39,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml { if (level == LogEventLevel.Error && area == LogArea.Binding && - mt == "Error binding to {Target}.{Property}: {Message}" && + mt == "Error in binding to {Target}.{Property}: {Message}" && pv.Length == 3 && pv[0] is ProgressBar && object.ReferenceEquals(pv[1], ProgressBar.ValueProperty) && diff --git a/tests/Avalonia.Styling.UnitTests/SetterTests.cs b/tests/Avalonia.Styling.UnitTests/SetterTests.cs index 4de90b7790..84536fa47b 100644 --- a/tests/Avalonia.Styling.UnitTests/SetterTests.cs +++ b/tests/Avalonia.Styling.UnitTests/SetterTests.cs @@ -27,7 +27,7 @@ namespace Avalonia.Styling.UnitTests var control = new TextBlock(); var subject = new BehaviorSubject("foo"); var descriptor = new InstancedBinding(subject); - var binding = Mock.Of(x => x.Initiate(control, TextBlock.TextProperty, null) == descriptor); + var binding = Mock.Of(x => x.Initiate(control, TextBlock.TextProperty, null, false) == descriptor); var style = Mock.Of(); var setter = new Setter(TextBlock.TextProperty, binding); diff --git a/tests/Avalonia.UnitTests/NotifyingBase.cs b/tests/Avalonia.UnitTests/NotifyingBase.cs index c1b7a24303..c91e55d34f 100644 --- a/tests/Avalonia.UnitTests/NotifyingBase.cs +++ b/tests/Avalonia.UnitTests/NotifyingBase.cs @@ -16,7 +16,7 @@ namespace Avalonia.UnitTests add { _propertyChanged += value; - ++SubscriptionCount; + ++PropertyChangedSubscriptionCount; } remove @@ -24,12 +24,12 @@ namespace Avalonia.UnitTests if (_propertyChanged?.GetInvocationList().Contains(value) == true) { _propertyChanged -= value; - --SubscriptionCount; + --PropertyChangedSubscriptionCount; } } } - public int SubscriptionCount + public int PropertyChangedSubscriptionCount { get; private set;