From abdbcac79df09a692b11d5521dee7b7b9e5db5e8 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 26 Jul 2016 12:32:04 -0400 Subject: [PATCH 01/60] Added BindingNotification. This will replace BindingError and IValidationStatus for bindings that want to notify binding/validation errors. Still WIP. --- src/Avalonia.Base/Avalonia.Base.csproj | 2 +- src/Avalonia.Base/AvaloniaObject.cs | 36 ++-- src/Avalonia.Base/Data/BindingError.cs | 59 ------ src/Avalonia.Base/Data/BindingNotification.cs | 169 ++++++++++++++++++ src/Avalonia.Base/IPriorityValueOwner.cs | 2 +- src/Avalonia.Base/PriorityBindingEntry.cs | 30 ++-- src/Avalonia.Base/PriorityLevel.cs | 4 +- src/Avalonia.Base/PriorityValue.cs | 6 +- .../Templates/MemberSelector.cs | 2 +- .../Avalonia.Markup/Data/ExpressionNode.cs | 16 +- .../Avalonia.Markup/Data/ExpressionSubject.cs | 19 +- .../Plugins/AvaloniaPropertyAccessorPlugin.cs | 2 +- .../Plugins/InpcPropertyAccessorPlugin.cs | 2 +- .../Data/Plugins/PropertyError.cs | 4 +- .../Avalonia.Markup/DefaultValueConverter.cs | 2 +- .../Avalonia.Base.UnitTests.v2.ncrunchproject | 7 +- .../AvaloniaObjectTests_Binding.cs | 17 +- .../AvaloniaObjectTests_Direct.cs | 9 +- .../Data/ExpressionObserverTests_Property.cs | 10 +- .../Data/ExpressionSubjectTests.cs | 2 +- .../DefaultValueConverterTests.cs | 2 +- 21 files changed, 266 insertions(+), 136 deletions(-) delete mode 100644 src/Avalonia.Base/Data/BindingError.cs create mode 100644 src/Avalonia.Base/Data/BindingNotification.cs diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index 75b5b9da29..fd5aaf97aa 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -43,7 +43,7 @@ Properties\SharedAssemblyInfo.cs - + diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index e6b88c71f8..35dae50d27 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -379,12 +379,11 @@ 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() + .OfType() .Subscribe(x => DataValidationChanged(property, x)); _directBindings.Add(subscription); @@ -487,7 +486,7 @@ namespace Avalonia } /// - void IPriorityValueOwner.DataValidationChanged(PriorityValue sender, IValidationStatus status) + void IPriorityValueOwner.DataValidationChanged(PriorityValue sender, BindingNotification status) { var property = sender.Property; DataValidationChanged(property, status); @@ -623,14 +622,14 @@ 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 error = value as BindingNotification; if (error == null) { @@ -674,26 +673,29 @@ namespace Avalonia /// private void DirectBindingSet(AvaloniaProperty property, object value) { - var error = value as BindingError; + var notification = value as BindingNotification; - if (error == null) + if (notification == null) { SetValue(property, value); } else { - if (error.UseFallbackValue) + if (notification.HasValue) { - SetValue(property, error.FallbackValue); + SetValue(property, notification.Value); } - Logger.Error( - LogArea.Binding, - this, - "Error binding to {Target}.{Property}: {Message}", - this, - property, - error.Exception.Message); + if (notification.ErrorType == BindingErrorType.Error) + { + Logger.Error( + LogArea.Binding, + this, + "Error binding to {Target}.{Property}: {Message}", + this, + property, + notification.Error.Message); + } } } 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..60387778fc --- /dev/null +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -0,0 +1,169 @@ +// 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 : IValidationStatus + { + /// + /// 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); + + /// + /// Initializes a new instance of the class. + /// + /// The binding value. + public BindingNotification(object value) + { + Value = value; + HasValue = true; + } + + /// + /// 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) + { + Value = fallbackValue; + HasValue = true; + } + + /// + /// Gets the value that should be passed to the target when + /// is true. + /// + public object Value { get; } + + /// + /// Gets a value indicating whether should be pushed to the target. + /// + public bool HasValue { get; } + + /// + /// Gets the error that occurred on the source, if any. + /// + public Exception Error { get; } + + /// + /// Gets the type of error that represents, if any. + /// + public BindingErrorType ErrorType { get; } + + bool IValidationStatus.IsValid => ErrorType == BindingErrorType.None; + + 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 || object.Equals(a.Error, b.Error)); + } + + public static bool operator !=(BindingNotification a, BindingNotification b) + { + return !(a == b); + } + + public override bool Equals(object obj) + { + return Equals(obj as BindingNotification); + } + + public bool Equals(BindingNotification other) + { + return this == other; + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } + + public BindingNotification WithError(Exception e) + { + if (e == null) + { + return this; + } + + if (Error != null) + { + e = new AggregateException(Error, e); + } + + if (HasValue) + { + return new BindingNotification(e, BindingErrorType.Error, Value); + } + else + { + return new BindingNotification(e, BindingErrorType.Error, Value); + } + } + } +} diff --git a/src/Avalonia.Base/IPriorityValueOwner.cs b/src/Avalonia.Base/IPriorityValueOwner.cs index 743eba4453..b5a0c0abc1 100644 --- a/src/Avalonia.Base/IPriorityValueOwner.cs +++ b/src/Avalonia.Base/IPriorityValueOwner.cs @@ -23,6 +23,6 @@ namespace Avalonia /// /// The source of the change. /// The validation status. - void DataValidationChanged(PriorityValue sender, IValidationStatus status); + void DataValidationChanged(PriorityValue sender, BindingNotification status); } } diff --git a/src/Avalonia.Base/PriorityBindingEntry.cs b/src/Avalonia.Base/PriorityBindingEntry.cs index 3368f45b82..85c8c7e55c 100644 --- a/src/Avalonia.Base/PriorityBindingEntry.cs +++ b/src/Avalonia.Base/PriorityBindingEntry.cs @@ -93,22 +93,28 @@ 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.ErrorType == BindingErrorType.Error) + { + _owner.Error(this, notification); + } + else if (notification.ErrorType == BindingErrorType.DataValidationError) + { + _owner.Validation(this, notification); + } + + if (notification.HasValue) + { + Value = notification.Value; + _owner.Changed(this); + } } - - 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..b056dd38b7 100644 --- a/src/Avalonia.Base/PriorityLevel.cs +++ b/src/Avalonia.Base/PriorityLevel.cs @@ -159,7 +159,7 @@ 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); } @@ -169,7 +169,7 @@ namespace Avalonia /// /// The entry that completed. /// The validation status. - public void Validation(PriorityBindingEntry entry, IValidationStatus validationStatus) + public void Validation(PriorityBindingEntry entry, BindingNotification validationStatus) { _owner.LevelValidation(this, validationStatus); } diff --git a/src/Avalonia.Base/PriorityValue.cs b/src/Avalonia.Base/PriorityValue.cs index 20434bc97d..715fca3915 100644 --- a/src/Avalonia.Base/PriorityValue.cs +++ b/src/Avalonia.Base/PriorityValue.cs @@ -184,7 +184,7 @@ namespace Avalonia /// /// The priority level of the changed entry. /// The validation status. - public void LevelValidation(PriorityLevel priorityLevel, IValidationStatus validationStatus) + public void LevelValidation(PriorityLevel priorityLevel, BindingNotification validationStatus) { _owner.DataValidationChanged(this, validationStatus); } @@ -194,7 +194,7 @@ namespace Avalonia /// /// 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, @@ -203,7 +203,7 @@ namespace Avalonia "Error binding to {Target}.{Property}: {Message}", _owner, Property, - error.Exception.Message); + error.Error.Message); } /// diff --git a/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs b/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs index be4287605c..84ba432753 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs @@ -57,7 +57,7 @@ namespace Avalonia.Markup.Xaml.Templates { return null; } - else if (result is BindingError) + else if (result is BindingNotification) { return null; } diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs index 90a654d9e6..b61261365b 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -108,14 +108,14 @@ namespace Avalonia.Markup.Data protected virtual void SendValidationStatus(IValidationStatus status) { //Even if elements only bound to sub-values, send validation changes along so they will be surfaced to the UI level. - if (_subject != null) - { - _subject.OnNext(status); - } - else - { - Next?.SendValidationStatus(status); - } + //if (_subject != null) + //{ + // _subject.OnNext(status); + //} + //else + //{ + // Next?.SendValidationStatus(status); + //} } protected virtual void Unsubscribe(object target) diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs index 0a3be26c18..49c959cbfe 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs @@ -126,16 +126,16 @@ namespace Avalonia.Markup.Data converted = TypeUtilities.Default(type); _inner.SetValue(converted, _priority); } - else if (converted is BindingError) + else if (converted is BindingNotification) { - var error = converted as BindingError; + var error = converted as BindingNotification; Logger.Error( LogArea.Binding, this, "Error binding to {Expression}: {Message}", _inner.Expression, - error.Exception.Message); + error.Error.Message); if (_fallbackValue != AvaloniaProperty.UnsetValue) { @@ -174,7 +174,7 @@ namespace Avalonia.Markup.Data private object ConvertValue(object value) { var converted = - value as BindingError ?? + value as BindingNotification ?? value as IValidationStatus ?? Converter.Convert( value, @@ -184,9 +184,9 @@ namespace Avalonia.Markup.Data if (_fallbackValue != AvaloniaProperty.UnsetValue && (converted == AvaloniaProperty.UnsetValue || - converted is BindingError)) + converted is BindingNotification)) { - var error = converted as BindingError; + var error = converted as BindingNotification; if (TypeUtilities.TryConvert( _targetType, @@ -196,14 +196,15 @@ namespace Avalonia.Markup.Data { if (error != null) { - converted = new BindingError(error.Exception, converted); + converted = new BindingNotification(error.Error, BindingErrorType.Error, converted); } } else { - converted = new BindingError( + converted = new BindingNotification( new InvalidCastException( - $"Could not convert FallbackValue '{_fallbackValue}' to '{_targetType}'")); + $"Could not convert FallbackValue '{_fallbackValue}' to '{_targetType}'"), + BindingErrorType.Error); } } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs index b2d73ab8fc..c46d3571dc 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs @@ -56,7 +56,7 @@ namespace Avalonia.Markup.Data.Plugins { 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 { diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs index 36046dd742..2596cc77a2 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs @@ -61,7 +61,7 @@ namespace Avalonia.Markup.Data.Plugins { 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)); } } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/PropertyError.cs b/src/Markup/Avalonia.Markup/Data/Plugins/PropertyError.cs index f73bb1fc94..f0c0e3fb1a 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/PropertyError.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/PropertyError.cs @@ -8,13 +8,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; } diff --git a/src/Markup/Avalonia.Markup/DefaultValueConverter.cs b/src/Markup/Avalonia.Markup/DefaultValueConverter.cs index 469b00d3ad..cde73b67a1 100644 --- a/src/Markup/Avalonia.Markup/DefaultValueConverter.cs +++ b/src/Markup/Avalonia.Markup/DefaultValueConverter.cs @@ -44,7 +44,7 @@ namespace Avalonia.Markup if (value != null) { var message = $"Could not convert '{value}' to '{targetType}'"; - return new BindingError(new InvalidCastException(message)); + return new BindingNotification(new InvalidCastException(message), BindingErrorType.Error); } return AvaloniaProperty.UnsetValue; 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..5c3459e3db 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs @@ -273,26 +273,31 @@ 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(); @@ -313,7 +318,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); diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs index 6f1f59b77b..cc3e5c4052 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs @@ -406,7 +406,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)); } @@ -419,7 +419,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)); } @@ -449,7 +452,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.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index 643b8fccab..7c0dbe27a5 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -81,11 +81,11 @@ namespace Avalonia.Markup.UnitTests.Data 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); + var error = result as BindingNotification; + Assert.IsType(error.Error); + Assert.Equal("Could not find CLR property 'Baz' on '1'", error.Error.Message); } [Fact] @@ -216,7 +216,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal(3, result.Count); Assert.Equal("bar", result[0]); - Assert.IsType(result[1]); + Assert.IsType(result[1]); Assert.Equal("baz", result[2]); sub.Dispose(); diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs index 0b6a507274..449f402850 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs @@ -55,7 +55,7 @@ namespace Avalonia.Markup.UnitTests.Data var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(double)); var result = await target.Take(1); - Assert.IsType(result); + Assert.IsType(result); } [Fact] 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 From 4906a472b019c94052d2e0e7ed0a2492d9d0d9fc Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 7 Aug 2016 19:33:17 +0200 Subject: [PATCH 02/60] Reimplemented data validation using BindingNotifications. --- src/Avalonia.Base/Avalonia.Base.csproj | 2 - src/Avalonia.Base/AvaloniaObject.cs | 28 +--- src/Avalonia.Base/Data/BindingNotification.cs | 14 +- src/Avalonia.Base/Data/IValidationStatus.cs | 17 --- .../Data/ObjectValidationStatus.cs | 44 ------ src/Avalonia.Controls/Control.cs | 6 +- src/Avalonia.Controls/TextBox.cs | 2 +- .../Avalonia.Markup/Avalonia.Markup.csproj | 5 +- .../Avalonia.Markup/Data/ExpressionNode.cs | 13 -- .../Data/ExpressionObserver.cs | 31 ++-- .../Avalonia.Markup/Data/ExpressionSubject.cs | 2 +- .../Plugins/AvaloniaPropertyAccessorPlugin.cs | 55 +++---- .../Data/Plugins/DataValidatiorBase.cs | 80 ++++++++++ .../Data/Plugins/ExceptionValidationPlugin.cs | 43 ++---- .../Data/Plugins/IDataValidationPlugin.cs | 36 +++++ .../Data/Plugins/IPropertyAccessor.cs | 5 +- .../Data/Plugins/IPropertyAccessorPlugin.cs | 5 +- .../Data/Plugins/IValidationPlugin.cs | 35 ----- .../Data/Plugins/IndeiValidationPlugin.cs | 107 +++++++++----- .../Plugins/InpcPropertyAccessorPlugin.cs | 114 ++++++++------- .../Data/Plugins/PropertyAccessorBase.cs | 68 +++++++++ .../Data/Plugins/PropertyError.cs | 7 + .../Plugins/ValidatingPropertyAccessorBase.cs | 46 ------ .../Data/PropertyAccessorNode.cs | 55 ++++--- .../AvaloniaPropertyRegistryTests.cs | 4 +- .../TextBoxTests_ValidationState.cs | 33 +++-- .../Avalonia.Markup.UnitTests.csproj | 5 +- .../Data/ExceptionValidatorTests.cs | 93 ------------ ...ExpressionObserverTests_DataValidation.cs} | 71 ++++++++- .../Data/ExpressionObserverTests_Property.cs | 2 +- .../Data/IndeiValidatorTests.cs | 109 ++++++++------ .../Plugins/ExceptionValidationPluginTests.cs | 71 +++++++++ .../Plugins/IndeiValidationPluginTests.cs | 138 ++++++++++++++++++ .../Data/BindingTests_Validation.cs | 8 +- 34 files changed, 789 insertions(+), 565 deletions(-) delete mode 100644 src/Avalonia.Base/Data/IValidationStatus.cs delete mode 100644 src/Avalonia.Base/Data/ObjectValidationStatus.cs create mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs create mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs delete mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/IValidationPlugin.cs create mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/PropertyAccessorBase.cs delete mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/ValidatingPropertyAccessorBase.cs delete mode 100644 tests/Avalonia.Markup.UnitTests/Data/ExceptionValidatorTests.cs rename tests/Avalonia.Markup.UnitTests/Data/{ExpressionObserverTests_Validation.cs => ExpressionObserverTests_DataValidation.cs} (58%) create mode 100644 tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs create mode 100644 tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index fd5aaf97aa..d9375e30ee 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -45,8 +45,6 @@ - - diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 35dae50d27..a1da97e824 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. /// @@ -497,7 +474,7 @@ namespace Avalonia /// /// The property whose validation state changed. /// The new validation state. - protected virtual void DataValidationChanged(AvaloniaProperty property, IValidationStatus status) + protected virtual void DataValidationChanged(AvaloniaProperty property, BindingNotification status) { } @@ -505,9 +482,8 @@ namespace Avalonia /// Updates the validation status of the current object. /// /// The new validation status. - protected void UpdateValidationState(IValidationStatus status) + protected void UpdateValidationState(BindingNotification status) { - ValidationStatus = ValidationStatus.UpdateValidationStatus(status); } /// diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index 60387778fc..992e4093fc 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -30,7 +30,7 @@ namespace Avalonia.Data /// Represents a binding notification that can be a valid binding value, or a binding or /// data validation error. /// - public class BindingNotification : IValidationStatus + public class BindingNotification { /// /// A binding notification representing the null value. @@ -77,7 +77,7 @@ namespace Avalonia.Data /// The type of the binding error. /// The fallback value. public BindingNotification(Exception error, BindingErrorType errorType, object fallbackValue) - : this(error) + : this(error, errorType) { Value = fallbackValue; HasValue = true; @@ -104,8 +104,6 @@ namespace Avalonia.Data /// public BindingErrorType ErrorType { get; } - bool IValidationStatus.IsValid => ErrorType == BindingErrorType.None; - public static bool operator ==(BindingNotification a, BindingNotification b) { if (object.ReferenceEquals(a, b)) @@ -121,7 +119,7 @@ namespace Avalonia.Data return a.HasValue == b.HasValue && a.ErrorType == b.ErrorType && (!a.HasValue || object.Equals(a.Value, b.Value)) && - (a.ErrorType == BindingErrorType.None || object.Equals(a.Error, b.Error)); + (a.ErrorType == BindingErrorType.None || ExceptionEquals(a.Error, b.Error)); } public static bool operator !=(BindingNotification a, BindingNotification b) @@ -165,5 +163,11 @@ namespace Avalonia.Data return new BindingNotification(e, BindingErrorType.Error, Value); } } + + private static bool ExceptionEquals(Exception a, Exception b) + { + return a?.GetType() == b?.GetType() && + a.Message == b.Message; + } } } 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/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.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 944f6b82ac..dba221c159 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -108,7 +108,7 @@ namespace Avalonia.Controls PseudoClass(IsEnabledCoreProperty, x => !x, ":disabled"); PseudoClass(IsFocusedProperty, ":focus"); PseudoClass(IsPointerOverProperty, ":pointerover"); - PseudoClass(ValidationStatusProperty, status => !status.IsValid, ":invalid"); + ////PseudoClass(ValidationStatusProperty, status => !status.IsValid, ":invalid"); } /// @@ -401,10 +401,10 @@ namespace Avalonia.Controls protected IPseudoClasses PseudoClasses => Classes; /// - protected override void DataValidationChanged(AvaloniaProperty property, IValidationStatus status) + protected override void DataValidationChanged(AvaloniaProperty property, BindingNotification status) { base.DataValidationChanged(property, status); - ValidationStatus.UpdateValidationStatus(status); + ////ValidationStatus.UpdateValidationStatus(status); } /// diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index f102c7254c..be8df58ba2 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -235,7 +235,7 @@ namespace Avalonia.Controls HandleTextInput(e.Text); } - protected override void DataValidationChanged(AvaloniaProperty property, IValidationStatus status) + protected override void DataValidationChanged(AvaloniaProperty property, BindingNotification status) { if (property == TextProperty) { diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 49a5a4a681..f26931d24e 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -48,7 +48,7 @@ - + @@ -59,8 +59,9 @@ + - + diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs index b61261365b..622a5f1029 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -105,19 +105,6 @@ namespace Avalonia.Markup.Data CurrentValue = reference; } - protected virtual void SendValidationStatus(IValidationStatus status) - { - //Even if elements only bound to sub-values, send validation changes along so they will be surfaced to the UI level. - //if (_subject != null) - //{ - // _subject.OnNext(status); - //} - //else - //{ - // Next?.SendValidationStatus(status); - //} - } - protected virtual void Unsubscribe(object target) { } diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 0b5fbc6d7d..83a0628c84 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -31,10 +31,11 @@ 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 IndeiValidationPlugin(), + ExceptionValidationPlugin.Instance, }; private readonly WeakReference _root; @@ -45,24 +46,24 @@ namespace Avalonia.Markup.Data private IDisposable _updateSubscription; private int _count; private readonly ExpressionNode _node; - private bool _enableValidation; + private bool _enableDataValidation; /// /// 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. + public ExpressionObserver(object root, string expression, bool enableDataValidation = false) { Contract.Requires(expression != null); _root = new WeakReference(root); - _enableValidation = enableValidation; + _enableDataValidation = enableDataValidation; if (!string.IsNullOrWhiteSpace(expression)) { - _node = ExpressionNodeBuilder.Build(expression, enableValidation); + _node = ExpressionNodeBuilder.Build(expression, enableDataValidation); } Expression = expression; @@ -73,21 +74,21 @@ 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. public ExpressionObserver( IObservable rootObservable, string expression, - bool enableValidation = false) + bool enableDataValidation = false) { Contract.Requires(rootObservable != null); Contract.Requires(expression != null); _rootObservable = rootObservable; - _enableValidation = enableValidation; + _enableDataValidation = enableDataValidation; if (!string.IsNullOrWhiteSpace(expression)) { - _node = ExpressionNodeBuilder.Build(expression, enableValidation); + _node = ExpressionNodeBuilder.Build(expression, enableDataValidation); } Expression = expression; @@ -99,12 +100,12 @@ 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. public ExpressionObserver( Func rootGetter, string expression, IObservable update, - bool enableValidation = false) + bool enableDataValidation = false) { Contract.Requires(rootGetter != null); Contract.Requires(expression != null); @@ -112,11 +113,11 @@ namespace Avalonia.Markup.Data _rootGetter = rootGetter; _update = update; - _enableValidation = enableValidation; + _enableDataValidation = enableDataValidation; if (!string.IsNullOrWhiteSpace(expression)) { - _node = ExpressionNodeBuilder.Build(expression, enableValidation); + _node = ExpressionNodeBuilder.Build(expression, enableDataValidation); } Expression = expression; diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs index 49c959cbfe..f7bbcd393b 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs @@ -175,7 +175,7 @@ namespace Avalonia.Markup.Data { var converted = value as BindingNotification ?? - value as IValidationStatus ?? + ////value as IValidationStatus ?? Converter.Convert( value, _targetType, diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/AvaloniaPropertyAccessorPlugin.cs index c46d3571dc..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,7 +35,7 @@ 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) { @@ -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/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..63c2b1bf24 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs @@ -10,23 +10,26 @@ namespace Avalonia.Markup.Data.Plugins /// /// Validates properties that report errors by throwing exceptions. /// - public class ExceptionValidationPlugin : IValidationPlugin + public class ExceptionValidationPlugin : IDataValidationPlugin { + /// + /// Gets the default instance of the / + /// public static ExceptionValidationPlugin Instance { get; } = new ExceptionValidationPlugin(); /// public bool Match(WeakReference reference) => 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 +37,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..7449c65245 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs @@ -0,0 +1,36 @@ +// 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. + /// True if the plugin can handle the object; otherwise false. + bool Match(WeakReference reference); + + /// + /// 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/IndeiValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs index f6b4aea614..8fb2568f30 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) => 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 2596cc77a2..a62132be86 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,43 +18,29 @@ 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 { @@ -65,78 +50,99 @@ namespace Avalonia.Markup.Data.Plugins } } - private class Accessor : IPropertyAccessor, IWeakSubscriber + private class Accessor : PropertyAccessorBase, IWeakSubscriber { private readonly WeakReference _reference; private readonly PropertyInfo _property; - private readonly Action _changed; - 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()); + _property.SetValue(_reference.Target, value); + 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)) + { + 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) + { + WeakSubscriptionManager.Subscribe( + inpc, + nameof(inpc.PropertyChanged), + this); + } + else { - _changed(Value); + Logger.Information( + LogArea.Binding, + this, + "Bound to property {Property} on {Source} which does not implement INotifyPropertyChanged", + _property.Name, + _reference.Target, + _reference.Target.GetType()); } } } 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 f0c0e3fb1a..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 @@ -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/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..8fcaa85e25 100644 --- a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs +++ b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs @@ -9,11 +9,12 @@ using System.Threading; using System.Threading.Tasks; using System.Windows.Input; using Avalonia.Data; +using Avalonia.Logging; using Avalonia.Markup.Data.Plugins; namespace Avalonia.Markup.Data { - internal class PropertyAccessorNode : ExpressionNode + internal class PropertyAccessorNode : ExpressionNode, IObserver { private IPropertyAccessor _accessor; private IDisposable _subscription; @@ -39,49 +40,55 @@ namespace Avalonia.Markup.Data { if (_accessor != null) { - return _accessor.SetValue(value, priority); + try { return _accessor.SetValue(value, priority); } catch { } } return false; } } + void IObserver.OnCompleted() + { + // Should not be called by IPropertyAccessor. + } + + void IObserver.OnError(Exception error) + { + // Should not be called by IPropertyAccessor. + } + + void IObserver.OnNext(object value) + { + SetCurrentValue(value); + } + protected override void SubscribeAndUpdate(WeakReference reference) { var instance = reference.Target; if (instance != null && instance != AvaloniaProperty.UnsetValue) { - var accessorPlugin = ExpressionObserver.PropertyAccessors.FirstOrDefault(x => x.Match(reference)); + var plugin = ExpressionObserver.PropertyAccessors.FirstOrDefault(x => x.Match(reference)); + var accessor = plugin?.Start(reference, PropertyName); - if (accessorPlugin != null) + if (_enableValidation) { - _accessor = ExceptionValidationPlugin.Instance.Start( - reference, - PropertyName, - accessorPlugin.Start(reference, PropertyName, SetCurrentValue), - SendValidationStatus); - - if (_enableValidation) + foreach (var validator in ExpressionObserver.DataValidators) { - foreach (var validationPlugin in ExpressionObserver.ValidationCheckers) + if (validator.Match(reference)) { - if (validationPlugin.Match(reference)) - { - _accessor = validationPlugin.Start(reference, PropertyName, _accessor, SendValidationStatus); - } + accessor = validator.Start(reference, PropertyName, accessor); } } - - if (_accessor != null) - { - SetCurrentValue(_accessor.Value); - return; - } } - } - CurrentValue = UnsetReference; + _accessor = accessor; + _accessor.Subscribe(this); + } + else + { + CurrentValue = UnsetReference; + } } protected override void Unsubscribe(object target) 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/TextBoxTests_ValidationState.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs index 6ff3629acf..1036b70d30 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs @@ -24,11 +24,12 @@ namespace Avalonia.Controls.UnitTests binding.EnableValidation = true; target.Bind(TextBox.TextProperty, binding); - Assert.True(target.ValidationStatus.IsValid); - target.Text = "20"; - Assert.False(target.ValidationStatus.IsValid); - target.Text = "1"; - Assert.True(target.ValidationStatus.IsValid); + Assert.True(false); + //Assert.True(target.ValidationStatus.IsValid); + //target.Text = "20"; + //Assert.False(target.ValidationStatus.IsValid); + //target.Text = "1"; + //Assert.True(target.ValidationStatus.IsValid); } } @@ -43,11 +44,12 @@ namespace Avalonia.Controls.UnitTests binding.EnableValidation = true; target.Bind(TextBox.TextProperty, binding); - Assert.True(target.ValidationStatus.IsValid); - target.Text = "foo"; - Assert.False(target.ValidationStatus.IsValid); - target.Text = "1"; - Assert.True(target.ValidationStatus.IsValid); + Assert.True(false); + //Assert.True(target.ValidationStatus.IsValid); + //target.Text = "foo"; + //Assert.False(target.ValidationStatus.IsValid); + //target.Text = "1"; + //Assert.True(target.ValidationStatus.IsValid); } } @@ -62,11 +64,12 @@ namespace Avalonia.Controls.UnitTests binding.EnableValidation = true; target.Bind(TextBox.TextProperty, binding); - Assert.True(target.ValidationStatus.IsValid); - target.Text = "20"; - Assert.False(target.ValidationStatus.IsValid); - target.Text = "1"; - Assert.True(target.ValidationStatus.IsValid); + Assert.True(false); + //Assert.True(target.ValidationStatus.IsValid); + //target.Text = "20"; + //Assert.False(target.ValidationStatus.IsValid); + //target.Text = "1"; + //Assert.True(target.ValidationStatus.IsValid); } } diff --git a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj index ae691bfc0f..045951882e 100644 --- a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj +++ b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj @@ -85,7 +85,8 @@ - + + @@ -97,7 +98,7 @@ - + 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_Validation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs similarity index 58% rename from tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Validation.cs rename to tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index 59c8965cfb..e245244358 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Validation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -14,21 +14,40 @@ using Xunit; namespace Avalonia.Markup.UnitTests.Data { - public class ExpressionObserverTests_Validation + public class ExpressionObserverTests_DataValidation { [Fact] - public void Exception_Validation_Sends_ValidationUpdate() + 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.Where(o => o is IValidationStatus).Subscribe(_ => validationMessageFound = true); + + 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 Disabled_Indei_Validation_Does_Not_Subscribe() + 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); @@ -50,6 +69,42 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal(0, data.SubscriptionCount); } + [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); + } + public class ExceptionTest : INotifyPropertyChanged { private int _mustBePositive; @@ -75,7 +130,7 @@ namespace Avalonia.Markup.UnitTests.Data } } - private class IndeiTest : INotifyDataErrorInfo + private class IndeiTest : INotifyDataErrorInfo, INotifyPropertyChanged { private int _mustBePositive; private Dictionary> _errors = new Dictionary>(); @@ -86,9 +141,11 @@ namespace Avalonia.Markup.UnitTests.Data get { return _mustBePositive; } set { + _mustBePositive = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MustBePositive))); + if (value >= 0) { - _mustBePositive = value; _errors.Remove(nameof(MustBePositive)); _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(MustBePositive))); } @@ -118,6 +175,8 @@ namespace Avalonia.Markup.UnitTests.Data } } + public event PropertyChangedEventHandler PropertyChanged; + public IEnumerable GetErrors(string propertyName) { IList result; diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index 7c0dbe27a5..3e081edad5 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -75,7 +75,7 @@ namespace Avalonia.Markup.UnitTests.Data } [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"); diff --git a/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs b/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs index 20bf164360..f4c38d112e 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections; +using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; using Avalonia.Data; @@ -13,6 +14,69 @@ namespace Avalonia.Markup.UnitTests.Data { public class IndeiValidatorTests { + [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)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), accessor); + var results = new List(); + + validator.Subscribe(x => results.Add(x)); + validator.SetValue(5, BindingPriority.LocalValue); + + Assert.Equal( + new[] + { + new BindingNotification(0), + new BindingNotification(5), + }, results); + } + + [Fact] + public void Setting_Validating_Property_To_Valid_Value_Returns_Successful_BindingNotification() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new IndeiValidationPlugin(); + var data = new Data { MustBePositive = 1 }; + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor); + var results = new List(); + + validator.Subscribe(x => results.Add(x)); + validator.SetValue(5, BindingPriority.LocalValue); + + Assert.Equal( + new[] + { + new BindingNotification(1), + new BindingNotification(5), + }, results); + } + + [Fact] + public void Setting_Validating_Property_To_Invalid_Value_Returns_DataValidationError() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new IndeiValidationPlugin(); + var data = new Data { MustBePositive = 1 }; + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor); + var results = new List(); + + validator.Subscribe(x => results.Add(x)); + validator.SetValue(-5, BindingPriority.LocalValue); + + Assert.Equal( + new[] + { + new BindingNotification(1), + new BindingNotification(new Exception("MustBePositive must be positive"), BindingErrorType.DataValidationError, -5), + }, results); + } + public class Data : INotifyPropertyChanged, INotifyDataErrorInfo { private int nonValidated; @@ -64,50 +128,5 @@ namespace Avalonia.Markup.UnitTests.Data } } } - - [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/ExceptionValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs new file mode 100644 index 0000000000..eb66384291 --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs @@ -0,0 +1,71 @@ +// 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; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using Avalonia.Data; +using Avalonia.Markup.Data.Plugins; +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 : INotifyPropertyChanged + { + private int _mustBePositive; + + public int MustBePositive + { + get { return _mustBePositive; } + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + if (value != _mustBePositive) + { + _mustBePositive = value; + NotifyPropertyChanged(); + } + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + } +} 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..6f62d5078e --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs @@ -0,0 +1,138 @@ +// 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.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.SubscriptionCount); + var sub = validator.Subscribe(_ => { }); + Assert.Equal(1, data.SubscriptionCount); + sub.Dispose(); + Assert.Equal(0, data.SubscriptionCount); + } + + public class Data : INotifyDataErrorInfo, INotifyPropertyChanged + { + private int _value; + private int _maximum; + private string _error; + private EventHandler _errorsChanged; + + public bool HasErrors => _error != null; + public int SubscriptionCount { get; private set; } + + public int Value + { + get { return _value; } + set + { + _value = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + UpdateError(); + } + } + + public int Maximum + { + get { return _maximum; } + set + { + _maximum = value; + UpdateError(); + } + } + + public event EventHandler ErrorsChanged + { + add { _errorsChanged += value; ++SubscriptionCount; } + remove { _errorsChanged -= value; --SubscriptionCount; } + } + + public event PropertyChangedEventHandler PropertyChanged; + + public 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; + _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + } + } + else + { + if (_error == null) + { + _error = "Must be less than Maximum"; + _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + } + } + } + } + } +} diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs index 0fed786f07..d0adb1e03f 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -26,7 +26,8 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data target.ValidationTest = -5; - Assert.False(target.ValidationStatus.IsValid); + Assert.True(false); + //Assert.False(target.ValidationStatus.IsValid); } [Fact] @@ -44,7 +45,8 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data target.Bind(TestControl.ValidationTestProperty, binding); target.ValidationTest = -5; - Assert.False(target.ValidationStatus.IsValid); + Assert.True(false); + //Assert.False(target.ValidationStatus.IsValid); } @@ -123,7 +125,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data } } - protected override void DataValidationChanged(AvaloniaProperty property, IValidationStatus status) + protected override void DataValidationChanged(AvaloniaProperty property, BindingNotification status) { if (property == ValidationTestProperty) { From ac427192df0db06a15370d7d1057681bd4cca728 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 9 Aug 2016 13:21:22 +0200 Subject: [PATCH 03/60] Use NotifyingBase in unit tests. And rename NotifyingBase.SubscriptionCount -> PropertyChangedSubscriptionCount as we also track ErrorsChanged subscription count. --- .../ExpressionObserverTests_DataValidation.cs | 38 ++++++------------- .../Data/ExpressionObserverTests_Indexer.cs | 2 +- .../ExpressionObserverTests_Observable.cs | 2 +- .../Data/ExpressionObserverTests_Property.cs | 34 ++++++++--------- .../Data/IndeiValidatorTests.cs | 25 +++--------- .../Plugins/ExceptionValidationPluginTests.cs | 14 ++----- .../Plugins/IndeiValidationPluginTests.cs | 7 ++-- tests/Avalonia.UnitTests/NotifyingBase.cs | 6 +-- 8 files changed, 45 insertions(+), 83 deletions(-) diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index e245244358..ff8f086e1f 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -10,6 +10,7 @@ using System.Reactive.Linq; using System.Runtime.CompilerServices; using Avalonia.Data; using Avalonia.Markup.Data; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Markup.UnitTests.Data @@ -54,7 +55,7 @@ namespace Avalonia.Markup.UnitTests.Data observer.Subscribe(_ => { }); - Assert.Equal(0, data.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); } [Fact] @@ -64,9 +65,9 @@ namespace Avalonia.Markup.UnitTests.Data var observer = new ExpressionObserver(data, nameof(data.MustBePositive), true); var sub = observer.Subscribe(_ => { }); - Assert.Equal(1, data.SubscriptionCount); + Assert.Equal(1, data.PropertyChangedSubscriptionCount); sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); } [Fact] @@ -105,7 +106,7 @@ namespace Avalonia.Markup.UnitTests.Data }, result); } - public class ExceptionTest : INotifyPropertyChanged + public class ExceptionTest : NotifyingBase { private int _mustBePositive; @@ -118,19 +119,14 @@ namespace Avalonia.Markup.UnitTests.Data { throw new ArgumentOutOfRangeException(nameof(value)); } + _mustBePositive = value; + RaisePropertyChanged(); } } - - public event PropertyChangedEventHandler PropertyChanged; - - private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } } - private class IndeiTest : INotifyDataErrorInfo, INotifyPropertyChanged + private class IndeiTest : NotifyingBase, INotifyDataErrorInfo { private int _mustBePositive; private Dictionary> _errors = new Dictionary>(); @@ -142,7 +138,7 @@ namespace Avalonia.Markup.UnitTests.Data set { _mustBePositive = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MustBePositive))); + RaisePropertyChanged(); if (value >= 0) { @@ -159,24 +155,14 @@ namespace Avalonia.Markup.UnitTests.Data public bool HasErrors => _mustBePositive >= 0; - public int SubscriptionCount { get; private set; } + public int ErrorsChangedSubscriptionCount { get; private set; } public event EventHandler ErrorsChanged { - add - { - _errorsChanged += value; - ++SubscriptionCount; - } - remove - { - _errorsChanged -= value; - --SubscriptionCount; - } + add { _errorsChanged += value; ++ErrorsChangedSubscriptionCount; } + remove { _errorsChanged -= value; --ErrorsChangedSubscriptionCount; } } - public event PropertyChangedEventHandler PropertyChanged; - public IEnumerable GetErrors(string propertyName) { IList result; diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs index b79498baae..524cabb096 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs @@ -189,7 +189,7 @@ namespace Avalonia.Markup.UnitTests.Data var expected = new[] { "bar", "bar2" }; Assert.Equal(expected, result); - Assert.Equal(0, data.Foo.SubscriptionCount); + Assert.Equal(0, data.Foo.PropertyChangedSubscriptionCount); } [Fact] diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs index c5bb2886b5..99f53f6979 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs @@ -47,7 +47,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal(new[] { AvaloniaProperty.UnsetValue, "foo" }, result); sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); + 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 3e081edad5..4e65723c75 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -111,7 +111,7 @@ namespace Avalonia.Markup.UnitTests.Data sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); } [Fact] @@ -139,7 +139,7 @@ namespace Avalonia.Markup.UnitTests.Data sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); } [Fact] @@ -156,8 +156,8 @@ namespace Avalonia.Markup.UnitTests.Data 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] @@ -175,9 +175,9 @@ namespace Avalonia.Markup.UnitTests.Data 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] @@ -196,9 +196,9 @@ namespace Avalonia.Markup.UnitTests.Data 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] @@ -221,10 +221,10 @@ namespace Avalonia.Markup.UnitTests.Data 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] @@ -327,8 +327,8 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal(new[] { "foo", "bar", AvaloniaProperty.UnsetValue }, result); - Assert.Equal(0, first.SubscriptionCount); - Assert.Equal(0, second.SubscriptionCount); + Assert.Equal(0, first.PropertyChangedSubscriptionCount); + Assert.Equal(0, second.PropertyChangedSubscriptionCount); } [Fact] @@ -351,7 +351,7 @@ namespace Avalonia.Markup.UnitTests.Data private interface INext { - int SubscriptionCount { get; } + int PropertyChangedSubscriptionCount { get; } } private class Class1 : NotifyingBase diff --git a/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs b/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs index f4c38d112e..dd2466cc14 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs @@ -8,6 +8,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using Avalonia.Data; using Avalonia.Markup.Data.Plugins; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Markup.UnitTests.Data @@ -77,14 +78,14 @@ namespace Avalonia.Markup.UnitTests.Data }, results); } - public class Data : INotifyPropertyChanged, INotifyDataErrorInfo + public class Data : NotifyingBase, INotifyDataErrorInfo { private int nonValidated; public int NonValidated { get { return nonValidated; } - set { nonValidated = value; NotifyPropertyChanged(); } + set { nonValidated = value; RaisePropertyChanged(); } } private int mustBePositive; @@ -92,29 +93,13 @@ namespace Avalonia.Markup.UnitTests.Data public int MustBePositive { get { return mustBePositive; } - set - { - mustBePositive = value; - NotifyErrorsChanged(); - } + set { mustBePositive = value; RaisePropertyChanged(); } } - public bool HasErrors - { - get - { - return MustBePositive > 0; - } - } + public bool HasErrors => 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)); diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs index eb66384291..4a34791008 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs @@ -3,11 +3,10 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Reactive.Linq; -using System.Runtime.CompilerServices; using Avalonia.Data; using Avalonia.Markup.Data.Plugins; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Markup.UnitTests.Data.Plugins @@ -38,7 +37,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins }, result); } - public class Data : INotifyPropertyChanged + public class Data : NotifyingBase { private int _mustBePositive; @@ -55,17 +54,10 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins if (value != _mustBePositive) { _mustBePositive = value; - NotifyPropertyChanged(); + RaisePropertyChanged(); } } } - - public event PropertyChangedEventHandler PropertyChanged; - - private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } } } } diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs index 6f62d5078e..9321b17d5d 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs @@ -8,6 +8,7 @@ using System.ComponentModel; using System.Reactive.Linq; using Avalonia.Data; using Avalonia.Markup.Data.Plugins; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Markup.UnitTests.Data.Plugins @@ -65,7 +66,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins Assert.Equal(0, data.SubscriptionCount); } - public class Data : INotifyDataErrorInfo, INotifyPropertyChanged + public class Data : NotifyingBase, INotifyDataErrorInfo { private int _value; private int _maximum; @@ -81,7 +82,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins set { _value = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + RaisePropertyChanged(); UpdateError(); } } @@ -102,8 +103,6 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins remove { _errorsChanged -= value; --SubscriptionCount; } } - public event PropertyChangedEventHandler PropertyChanged; - public IEnumerable GetErrors(string propertyName) { if (propertyName == nameof(Value) && _error != null) 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; From 4ffae3eb55f9cecf3e0319becd6add94a876f6f0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 9 Aug 2016 13:35:40 +0200 Subject: [PATCH 04/60] Added IndeiBase And use it as base class for test data that implements INotifyDataErrorInfo. Removed IndeiValidatorTests as its been superceeded by IndeiValidationPluginTests. --- .../ExpressionObserverTests_DataValidation.cs | 25 ++-- .../Data/IndeiBase.cs | 32 +++++ .../Data/IndeiValidatorTests.cs | 117 ------------------ .../Plugins/IndeiValidationPluginTests.cs | 26 ++-- 4 files changed, 48 insertions(+), 152 deletions(-) create mode 100644 tests/Avalonia.Markup.UnitTests/Data/IndeiBase.cs delete mode 100644 tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index ff8f086e1f..f8cb702cb9 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -55,7 +55,7 @@ namespace Avalonia.Markup.UnitTests.Data observer.Subscribe(_ => { }); - Assert.Equal(0, data.PropertyChangedSubscriptionCount); + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); } [Fact] @@ -65,9 +65,9 @@ namespace Avalonia.Markup.UnitTests.Data var observer = new ExpressionObserver(data, nameof(data.MustBePositive), true); var sub = observer.Subscribe(_ => { }); - Assert.Equal(1, data.PropertyChangedSubscriptionCount); + Assert.Equal(1, data.ErrorsChangedSubscriptionCount); sub.Dispose(); - Assert.Equal(0, data.PropertyChangedSubscriptionCount); + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); } [Fact] @@ -126,11 +126,10 @@ namespace Avalonia.Markup.UnitTests.Data } } - private class IndeiTest : NotifyingBase, INotifyDataErrorInfo + private class IndeiTest : IndeiBase { private int _mustBePositive; private Dictionary> _errors = new Dictionary>(); - private EventHandler _errorsChanged; public int MustBePositive { @@ -143,27 +142,19 @@ namespace Avalonia.Markup.UnitTests.Data if (value >= 0) { _errors.Remove(nameof(MustBePositive)); - _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(MustBePositive))); + RaiseErrorsChanged(nameof(MustBePositive)); } else { _errors[nameof(MustBePositive)] = new[] { "Must be positive" }; - _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(MustBePositive))); + RaiseErrorsChanged(nameof(MustBePositive)); } } } - public bool HasErrors => _mustBePositive >= 0; + public override bool HasErrors => _mustBePositive >= 0; - public int ErrorsChangedSubscriptionCount { get; private set; } - - public event EventHandler ErrorsChanged - { - add { _errorsChanged += value; ++ErrorsChangedSubscriptionCount; } - remove { _errorsChanged -= value; --ErrorsChangedSubscriptionCount; } - } - - public IEnumerable GetErrors(string propertyName) + public override IEnumerable GetErrors(string propertyName) { IList result; _errors.TryGetValue(propertyName, out result); 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 dd2466cc14..0000000000 --- a/tests/Avalonia.Markup.UnitTests/Data/IndeiValidatorTests.cs +++ /dev/null @@ -1,117 +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.Runtime.CompilerServices; -using Avalonia.Data; -using Avalonia.Markup.Data.Plugins; -using Avalonia.UnitTests; -using Xunit; - -namespace Avalonia.Markup.UnitTests.Data -{ - public class IndeiValidatorTests - { - [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)); - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.NonValidated), accessor); - var results = new List(); - - validator.Subscribe(x => results.Add(x)); - validator.SetValue(5, BindingPriority.LocalValue); - - Assert.Equal( - new[] - { - new BindingNotification(0), - new BindingNotification(5), - }, results); - } - - [Fact] - public void Setting_Validating_Property_To_Valid_Value_Returns_Successful_BindingNotification() - { - var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); - var validatorPlugin = new IndeiValidationPlugin(); - var data = new Data { MustBePositive = 1 }; - var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive)); - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor); - var results = new List(); - - validator.Subscribe(x => results.Add(x)); - validator.SetValue(5, BindingPriority.LocalValue); - - Assert.Equal( - new[] - { - new BindingNotification(1), - new BindingNotification(5), - }, results); - } - - [Fact] - public void Setting_Validating_Property_To_Invalid_Value_Returns_DataValidationError() - { - var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); - var validatorPlugin = new IndeiValidationPlugin(); - var data = new Data { MustBePositive = 1 }; - var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive)); - var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.MustBePositive), accessor); - var results = new List(); - - validator.Subscribe(x => results.Add(x)); - validator.SetValue(-5, BindingPriority.LocalValue); - - Assert.Equal( - new[] - { - new BindingNotification(1), - new BindingNotification(new Exception("MustBePositive must be positive"), BindingErrorType.DataValidationError, -5), - }, results); - } - - public class Data : NotifyingBase, INotifyDataErrorInfo - { - private int nonValidated; - - public int NonValidated - { - get { return nonValidated; } - set { nonValidated = value; RaisePropertyChanged(); } - } - - private int mustBePositive; - - public int MustBePositive - { - get { return mustBePositive; } - set { mustBePositive = value; RaisePropertyChanged(); } - } - - public bool HasErrors => MustBePositive > 0; - - public event EventHandler ErrorsChanged; - - 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"; - } - } - } - } -} diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs index 9321b17d5d..788bc25a34 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs @@ -4,11 +4,9 @@ using System; using System.Collections; using System.Collections.Generic; -using System.ComponentModel; using System.Reactive.Linq; using Avalonia.Data; using Avalonia.Markup.Data.Plugins; -using Avalonia.UnitTests; using Xunit; namespace Avalonia.Markup.UnitTests.Data.Plugins @@ -59,22 +57,20 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins 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.SubscriptionCount); + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); var sub = validator.Subscribe(_ => { }); - Assert.Equal(1, data.SubscriptionCount); + Assert.Equal(1, data.ErrorsChangedSubscriptionCount); sub.Dispose(); - Assert.Equal(0, data.SubscriptionCount); + Assert.Equal(0, data.ErrorsChangedSubscriptionCount); } - public class Data : NotifyingBase, INotifyDataErrorInfo + internal class Data : IndeiBase { private int _value; private int _maximum; private string _error; - private EventHandler _errorsChanged; - public bool HasErrors => _error != null; - public int SubscriptionCount { get; private set; } + public override bool HasErrors => _error != null; public int Value { @@ -97,13 +93,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins } } - public event EventHandler ErrorsChanged - { - add { _errorsChanged += value; ++SubscriptionCount; } - remove { _errorsChanged -= value; --SubscriptionCount; } - } - - public IEnumerable GetErrors(string propertyName) + public override IEnumerable GetErrors(string propertyName) { if (propertyName == nameof(Value) && _error != null) { @@ -120,7 +110,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins if (_error != null) { _error = null; - _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + RaiseErrorsChanged(nameof(Value)); } } else @@ -128,7 +118,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins if (_error == null) { _error = "Must be less than Maximum"; - _errorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + RaiseErrorsChanged(nameof(Value)); } } } From 2f7229565789ca2e6c202d98d79bd4dffa21358e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 10 Aug 2016 11:12:31 +0200 Subject: [PATCH 05/60] Started refactor of ExpressionObserver. Tying to be more "rx", but also will allow us to move forward on BindingNotification changes. --- src/Avalonia.Base/Avalonia.Base.csproj | 1 + src/Avalonia.Base/Utilities/WeakObservable.cs | 54 +++++ .../Templates/MemberSelector.cs | 51 ++--- .../Avalonia.Markup/Avalonia.Markup.csproj | 1 + .../Data/EmptyExpressionNode.cs | 16 ++ .../Avalonia.Markup/Data/ExpressionNode.cs | 153 +++++++++----- .../Data/ExpressionObserver.cs | 196 ++++-------------- .../Avalonia.Markup/Data/IndexerNode.cs | 187 +++++++---------- .../Avalonia.Markup/Data/LogicalNotNode.cs | 28 ++- .../Data/PropertyAccessorNode.cs | 127 ++---------- .../Avalonia.Markup.UnitTests.csproj | 2 +- .../ExpressionObserverTests_DataValidation.cs | 58 +++++- .../Data/ExpressionObserverTests_Indexer.cs | 2 +- .../Data/ExpressionObserverTests_Lifetime.cs | 44 ++-- .../Data/ExpressionObserverTests_Negation.cs | 17 +- .../Data/ExpressionObserverTests_Property.cs | 109 +++++++++- .../Data/ExpressionObserverTests_SetValue.cs | 10 +- 17 files changed, 564 insertions(+), 492 deletions(-) create mode 100644 src/Avalonia.Base/Utilities/WeakObservable.cs create mode 100644 src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index d9375e30ee..963726c158 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -117,6 +117,7 @@ + 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/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs b/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs index 84ba432753..7e9d7c00b8 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs @@ -30,39 +30,40 @@ namespace Avalonia.Markup.Xaml.Templates public object Select(object o) { - if (string.IsNullOrEmpty(MemberName)) - { - return o; - } + throw new NotImplementedException(); + ////if (string.IsNullOrEmpty(MemberName)) + ////{ + //// return o; + ////} - if (_expressionNode == null) - { - _expressionNode = ExpressionNodeBuilder.Build(MemberName); + ////if (_expressionNode == null) + ////{ + //// _expressionNode = ExpressionNodeBuilder.Build(MemberName); - _memberValueNode = _expressionNode; + //// _memberValueNode = _expressionNode; - while (_memberValueNode.Next != null) - { - _memberValueNode = _memberValueNode.Next; - } - } + //// while (_memberValueNode.Next != null) + //// { + //// _memberValueNode = _memberValueNode.Next; + //// } + ////} - _expressionNode.Target = new WeakReference(o); + ////_expressionNode.Target = new WeakReference(o); - object result = _memberValueNode.CurrentValue.Target; + ////object result = _memberValueNode.CurrentValue.Target; - _expressionNode.Target = null; + ////_expressionNode.Target = null; - if (result == AvaloniaProperty.UnsetValue) - { - return null; - } - else if (result is BindingNotification) - { - return null; - } + ////if (result == AvaloniaProperty.UnsetValue) + ////{ + //// return null; + ////} + ////else if (result is BindingNotification) + ////{ + //// return null; + ////} - return result; + ////return 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 f26931d24e..9324503cb0 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -42,6 +42,7 @@ Properties\SharedAssemblyInfo.cs + diff --git a/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs new file mode 100644 index 0000000000..d0133f161e --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs @@ -0,0 +1,16 @@ +// 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 + { + protected override IObservable StartListening(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 622a5f1029..26e1234d34 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -2,111 +2,154 @@ // 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; 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; _target = value; - if (newInstance != null) - { - SubscribeAndUpdate(_target); - } - else - { - CurrentValue = UnsetReference; - } - - if (Next != null) + if (running) { - Next.Target = _value; + _valueSubscription = StartListeningCore(); } } } } - 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 = StartListeningCore(); + + return Disposable.Create(() => { - _value = value; + _valueSubscription?.Dispose(); + _valueSubscription = 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 StartListening(WeakReference reference) { - if (Next != null) + return Observable.Return(reference.Target); + } + + protected virtual void NextValueChanged(object value) + { + _observer.OnNext(value); + } + + private IDisposable StartListeningCore() + { + 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 = StartListening(_target); } + + return source.Subscribe(TargetValueChanged); } - protected virtual void SubscribeAndUpdate(WeakReference reference) + private void TargetValueChanged(object value) { - CurrentValue = reference; + var notification = value as BindingNotification; + + if (notification == null) + { + if (Next != null) + { + Next.Target = new WeakReference(value); + } + else + { + _observer.OnNext(value); + } + } + else + { + if (notification.Error != null) + { + _observer.OnNext(notification); + } + else if (notification.HasValue) + { + if (Next != null) + { + Next.Target = new WeakReference(notification.Value); + } + else + { + _observer.OnNext(value); + } + } + } } - protected virtual void Unsubscribe(object target) + private BindingNotification TargetNullNotification() { + // TODO: Work out a way to give a more useful error message here. + return new BindingNotification(new NullReferenceException(), BindingErrorType.Error); } } } diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 83a0628c84..2e4c98fe82 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; @@ -38,15 +39,11 @@ namespace Avalonia.Markup.Data ExceptionValidationPlugin.Instance, }; - 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; + private static readonly object UninitializedValue = new object(); private readonly ExpressionNode _node; - private bool _enableDataValidation; + private readonly Subject _finished; + private readonly object _root; + private IObservable _result; /// /// Initializes a new instance of the class. @@ -58,15 +55,9 @@ namespace Avalonia.Markup.Data { Contract.Requires(expression != null); - _root = new WeakReference(root); - _enableDataValidation = enableDataValidation; - - if (!string.IsNullOrWhiteSpace(expression)) - { - _node = ExpressionNodeBuilder.Build(expression, enableDataValidation); - } - Expression = expression; + _node = Parse(expression, enableDataValidation); + _root = new WeakReference(root); } /// @@ -83,15 +74,10 @@ namespace Avalonia.Markup.Data Contract.Requires(rootObservable != null); Contract.Requires(expression != null); - _rootObservable = rootObservable; - _enableDataValidation = enableDataValidation; - - if (!string.IsNullOrWhiteSpace(expression)) - { - _node = ExpressionNodeBuilder.Build(expression, enableDataValidation); - } - Expression = expression; + _node = Parse(expression, enableDataValidation); + _finished = new Subject(); + _root = rootObservable; } /// @@ -111,16 +97,12 @@ namespace Avalonia.Markup.Data Contract.Requires(expression != null); Contract.Requires(update != null); - _rootGetter = rootGetter; - _update = update; - _enableDataValidation = enableDataValidation; - - if (!string.IsNullOrWhiteSpace(expression)) - { - _node = ExpressionNodeBuilder.Build(expression, enableDataValidation); - } - Expression = expression; + _node = Parse(expression, enableDataValidation); + _finished = new Subject(); + + _node.Target = new WeakReference(rootGetter()); + _root = update.Select(x => rootGetter()); } /// @@ -134,21 +116,7 @@ namespace Avalonia.Markup.Data /// 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; } /// @@ -160,42 +128,11 @@ 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(); - } - } - } + public Type ResultType => (Leaf as PropertyAccessorNode)?.PropertyType; /// string IDescription.Description => Expression; - /// - /// Gets the root expression node. Used for testing. - /// - internal ExpressionNode Node => _node; - /// /// Gets the leaf node. /// @@ -212,94 +149,51 @@ 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) + if (_finished != null) { - source = source.TakeUntil(_rootObservable.LastOrDefaultAsync()); + source = source.TakeUntil(_finished); } - else if (_update != null) - { - source = source.TakeUntil(_update.LastOrDefaultAsync()); - } - - var subscription = source.Subscribe(observer); - return Disposable.Create(() => - { - DecrementCount(); - subscription.Dispose(); - }); + _result = Observable.Using(StartRoot, _ => source) + .Publish(UninitializedValue) + .RefCount() + .Where(x => x != UninitializedValue); } - 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 IDisposable StartRoot() { - if (_count++ == 0 && _node != null) - { - if (_rootGetter != null) - { - _node.Target = new WeakReference(_rootGetter()); + var observable = _root as IObservable; - if (_update != null) - { - _updateSubscription = _update.Subscribe(x => - _node.Target = new WeakReference(_rootGetter())); - } - } - else if (_rootObservable != null) - { - _rootObserverSubscription = _rootObservable.Subscribe(x => - _node.Target = new WeakReference(x)); - } - else - { - _node.Target = _root; - } + if (observable != null) + { + return observable.Subscribe( + x => _node.Target = new WeakReference(x), + _ => _finished.OnNext(Unit.Default), + () => _finished.OnNext(Unit.Default)); } - } - - private void DecrementCount() - { - if (--_count == 0 && _node != null) + else { - if (_rootObserverSubscription != null) - { - _rootObserverSubscription.Dispose(); - _rootObserverSubscription = null; - } - - if (_updateSubscription != null) - { - _updateSubscription.Dispose(); - _updateSubscription = null; - } - - _node.Target = null; + _node.Target = (WeakReference)_root; + return Disposable.Empty; } } } diff --git a/src/Markup/Avalonia.Markup/Data/IndexerNode.cs b/src/Markup/Avalonia.Markup/Data/IndexerNode.cs index 8849e7edbc..f9615ee804 100644 --- a/src/Markup/Avalonia.Markup/Data/IndexerNode.cs +++ b/src/Markup/Avalonia.Markup/Data/IndexerNode.cs @@ -10,130 +10,47 @@ 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; } - - void IWeakSubscriber.OnEvent(object sender, NotifyCollectionChangedEventArgs e) - { - 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) + protected override IObservable StartListening(WeakReference reference) { - 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 +58,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 +88,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 +111,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 +144,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 +162,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 +171,16 @@ namespace Avalonia.Markup.Data { return indexer; } + foreach (var property in typeInfo.DeclaredProperties) { if (property.GetIndexParameters().Any()) { return property; } - } + } } + return null; } @@ -273,5 +203,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..0d96830024 100644 --- a/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs +++ b/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs @@ -3,21 +3,15 @@ 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) + protected override void NextValueChanged(object value) { - return false; - } - - public override IDisposable Subscribe(IObserver observer) - { - return Next.Select(Negate).Subscribe(observer); + base.NextValueChanged(Negate(value)); } private static object Negate(object v) @@ -34,6 +28,12 @@ namespace Avalonia.Markup.Data { return !result; } + else + { + return new BindingNotification( + new InvalidCastException($"Unable to convert '{s}' to bool."), + BindingErrorType.Error); + } } else { @@ -42,9 +42,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/PropertyAccessorNode.cs b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs index 8fcaa85e25..35fc444ec1 100644 --- a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs +++ b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs @@ -3,22 +3,17 @@ 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.Logging; using Avalonia.Markup.Data.Plugins; namespace Avalonia.Markup.Data { - internal class PropertyAccessorNode : ExpressionNode, IObserver + internal class PropertyAccessorNode : ExpressionNode { - private IPropertyAccessor _accessor; - private IDisposable _subscription; private bool _enableValidation; + private IPropertyAccessor _accessor; public PropertyAccessorNode(string propertyName, bool enableValidation) { @@ -30,120 +25,40 @@ namespace Avalonia.Markup.Data 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); - } - else - { - if (_accessor != null) - { - try { return _accessor.SetValue(value, priority); } catch { } - } - - return false; + try { return _accessor.SetValue(value, priority); } catch { } } - } - - void IObserver.OnCompleted() - { - // Should not be called by IPropertyAccessor. - } - void IObserver.OnError(Exception error) - { - // Should not be called by IPropertyAccessor. - } - - void IObserver.OnNext(object value) - { - SetCurrentValue(value); + return false; } - protected override void SubscribeAndUpdate(WeakReference reference) + protected override IObservable StartListening(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 plugin = ExpressionObserver.PropertyAccessors.FirstOrDefault(x => x.Match(reference)); - var accessor = plugin?.Start(reference, PropertyName); - - if (_enableValidation) + foreach (var validator in ExpressionObserver.DataValidators) { - foreach (var validator in ExpressionObserver.DataValidators) + if (validator.Match(reference)) { - if (validator.Match(reference)) - { - accessor = validator.Start(reference, PropertyName, accessor); - } + accessor = validator.Start(reference, PropertyName, accessor); } } - - _accessor = accessor; - _accessor.Subscribe(this); - } - else - { - 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/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj index 045951882e..6f92e88337 100644 --- a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj +++ b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj @@ -85,6 +85,7 @@ + @@ -100,7 +101,6 @@ - diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index f8cb702cb9..8789862b3a 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -4,10 +4,8 @@ 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 Avalonia.UnitTests; @@ -106,6 +104,48 @@ namespace Avalonia.Markup.UnitTests.Data }, 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 NullReferenceException(), BindingErrorType.Error), + }, result); + } + public class ExceptionTest : NotifyingBase { private int _mustBePositive; @@ -161,5 +201,19 @@ namespace Avalonia.Markup.UnitTests.Data 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 524cabb096..75cf606042 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs @@ -215,7 +215,7 @@ namespace Avalonia.Markup.UnitTests.Data { Func> run = () => { - var source = new NonIntegerIndexer(); + var source = new { Foo = new NonIntegerIndexer() }; var target = new ExpressionObserver(source, "Foo"); return Tuple.Create(target, new WeakReference(source)); }; 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_Property.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index 4e65723c75..043e85cae3 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); } @@ -71,6 +73,8 @@ 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); } @@ -88,6 +92,23 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal("Could not find CLR property 'Baz' on '1'", error.Error.Message); } + [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(1, result.Count); + Assert.IsType(result[0]); + + var error = result[0] as BindingNotification; + Assert.IsType(error.Error); + Assert.Equal("Object reference not set to an instance of an object.", error.Error.Message); + } + [Fact] public void Should_Have_Null_ResultType_For_Broken_Chain() { @@ -151,8 +172,9 @@ 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(); @@ -170,8 +192,9 @@ 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(); @@ -192,7 +215,14 @@ namespace Avalonia.Markup.UnitTests.Data data.Next = null; data.Next = new Class2 { Bar = "baz" }; - Assert.Equal(new[] { "bar", AvaloniaProperty.UnsetValue, "baz" }, result); + Assert.Equal( + new object[] + { + "bar", + new BindingNotification(new NullReferenceException(), BindingErrorType.Error), + "baz" + }, + result); sub.Dispose(); @@ -258,17 +288,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 +350,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,7 +364,10 @@ 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] @@ -297,7 +376,10 @@ namespace Avalonia.Markup.UnitTests.Data var data = new Class1(); var target = new ExpressionObserver(data, "Next.Bar"); - Assert.False(target.SetValue("baz")); + using (target.Subscribe(_ => { })) + { + Assert.False(target.SetValue("baz")); + } } [Fact] @@ -306,7 +388,7 @@ namespace Avalonia.Markup.UnitTests.Data var target = new ExpressionObserver((object)null, "Foo"); var result = await target.Take(1); - Assert.Equal(AvaloniaProperty.UnsetValue, result); + Assert.Equal(new BindingNotification(new NullReferenceException(), BindingErrorType.Error), result); } [Fact] @@ -325,7 +407,14 @@ namespace Avalonia.Markup.UnitTests.Data root = null; update.OnNext(Unit.Default); - Assert.Equal(new[] { "foo", "bar", AvaloniaProperty.UnsetValue }, result); + Assert.Equal( + new object[] + { + "foo", + "bar", + new BindingNotification(new NullReferenceException(), BindingErrorType.Error) + }, + result); Assert.Equal(0, first.PropertyChangedSubscriptionCount); Assert.Equal(0, second.PropertyChangedSubscriptionCount); 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); } From c5c60c483a645dae43cb1e714c24c28f20fda608 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 Aug 2016 10:12:13 +0200 Subject: [PATCH 06/60] Add concept of "value plugins". These are used to extensibly handle special values like `Task` and `IObservable<>`. Previously this was baked into the expression observer architecture with a TODO comment saying that it needs to be extensible. --- .../Avalonia.Markup/Avalonia.Markup.csproj | 3 + .../Avalonia.Markup/Data/ExpressionNode.cs | 49 ++++++++--- .../Data/ExpressionObserver.cs | 13 ++- .../Data/Plugins/ExceptionValidationPlugin.cs | 5 -- .../Data/Plugins/IValuePlugin.cs | 29 +++++++ .../Data/Plugins/ObservableValuePlugin.cs | 44 ++++++++++ .../Data/Plugins/TaskValuePlugin.cs | 82 +++++++++++++++++++ .../ExpressionObserverTests_Observable.cs | 45 +++++++++- .../Data/ExpressionObserverTests_Task.cs | 75 ++++++++++++++++- 9 files changed, 323 insertions(+), 22 deletions(-) create mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/IValuePlugin.cs create mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/ObservableValuePlugin.cs create mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/TaskValuePlugin.cs diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 9324503cb0..780c05d18d 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -60,6 +60,9 @@ + + + diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs index 26e1234d34..905cdcd713 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -17,6 +17,7 @@ namespace Avalonia.Markup.Data private WeakReference _target = UnsetReference; private IDisposable _valueSubscription; private IObserver _observer; + private IDisposable _valuePluginSubscription; public ExpressionNode Next { get; set; } @@ -35,6 +36,7 @@ namespace Avalonia.Markup.Data { _valueSubscription?.Dispose(); _valueSubscription = null; + _valuePluginSubscription?.Dispose(); _target = value; if (running) @@ -60,6 +62,8 @@ namespace Avalonia.Markup.Data { _valueSubscription?.Dispose(); _valueSubscription = null; + _valuePluginSubscription?.Dispose(); + _valuePluginSubscription = null; nextSubscription?.Dispose(); _observer = null; }); @@ -117,13 +121,16 @@ namespace Avalonia.Markup.Data if (notification == null) { - if (Next != null) + if (!HandleSpecialValue(value)) { - Next.Target = new WeakReference(value); - } - else - { - _observer.OnNext(value); + if (Next != null) + { + Next.Target = new WeakReference(value); + } + else + { + _observer.OnNext(value); + } } } else @@ -134,16 +141,38 @@ namespace Avalonia.Markup.Data } else if (notification.HasValue) { - if (Next != null) + if (!HandleSpecialValue(notification.Value)) { - Next.Target = new WeakReference(notification.Value); + if (Next != null) + { + Next.Target = new WeakReference(notification.Value); + } + else + { + _observer.OnNext(value); + } } - else + } + } + } + + private bool HandleSpecialValue(object value) + { + if (_valuePluginSubscription == null) + { + var reference = new WeakReference(value); + + foreach (var plugin in ExpressionObserver.ValueHandlers) + { + if (plugin.Match(reference)) { - _observer.OnNext(value); + _valuePluginSubscription = plugin.Start(reference)?.Subscribe(TargetValueChanged); + return true; } } } + + return false; } private BindingNotification TargetNullNotification() diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 2e4c98fe82..77d47cbdfc 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -36,7 +36,18 @@ namespace Avalonia.Markup.Data new List { new IndeiValidationPlugin(), - ExceptionValidationPlugin.Instance, + new ExceptionValidationPlugin(), + }; + + /// + /// 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(); diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs index 63c2b1bf24..8c3c10aeb9 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs @@ -12,11 +12,6 @@ namespace Avalonia.Markup.Data.Plugins /// public class ExceptionValidationPlugin : IDataValidationPlugin { - /// - /// Gets the default instance of the / - /// - public static ExceptionValidationPlugin Instance { get; } = new ExceptionValidationPlugin(); - /// public bool Match(WeakReference reference) => true; 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/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/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/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs index 99f53f6979..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,7 +45,47 @@ 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.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_Task.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs index 3d4c0b1b43..48b93107b1 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,7 +61,74 @@ 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 = Task.FromException(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); } } From daff817ce4f1bf4010ba2aeb55c59f257fa2809e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 Aug 2016 13:23:38 +0200 Subject: [PATCH 07/60] Subscribe to ExpressionSubject before setting value. The inner `ExpressionObserver` must be subscribed to as setting the value requires the expression to be evaluated. --- .../Data/ExpressionObserver.cs | 4 +- .../Avalonia.Markup/Data/ExpressionSubject.cs | 85 ++++++++++--------- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 77d47cbdfc..a29f36f899 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -123,7 +123,9 @@ 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) { diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs index f7bbcd393b..b26ec3402e 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs @@ -111,56 +111,59 @@ namespace Avalonia.Markup.Data /// public void OnNext(object value) { - var type = _inner.ResultType; - - if (type != null) + using (_inner.Subscribe(_ => { })) { - var converted = Converter.ConvertBack( - value, - type, - ConverterParameter, - CultureInfo.CurrentUICulture); + var type = _inner.ResultType; - if (converted == AvaloniaProperty.UnsetValue) - { - converted = TypeUtilities.Default(type); - _inner.SetValue(converted, _priority); - } - else if (converted is BindingNotification) + if (type != null) { - var error = converted as BindingNotification; + var converted = Converter.ConvertBack( + value, + type, + ConverterParameter, + CultureInfo.CurrentUICulture); - Logger.Error( - LogArea.Binding, - this, - "Error binding to {Expression}: {Message}", - _inner.Expression, - error.Error.Message); - - if (_fallbackValue != AvaloniaProperty.UnsetValue) + if (converted == AvaloniaProperty.UnsetValue) { - if (TypeUtilities.TryConvert( - type, - _fallbackValue, - CultureInfo.InvariantCulture, - out converted)) - { - _inner.SetValue(converted, _priority); - } - else + converted = TypeUtilities.Default(type); + _inner.SetValue(converted, _priority); + } + else if (converted is BindingNotification) + { + var error = converted as BindingNotification; + + Logger.Error( + LogArea.Binding, + this, + "Error binding to {Expression}: {Message}", + _inner.Expression, + error.Error.Message); + + if (_fallbackValue != AvaloniaProperty.UnsetValue) { - Logger.Error( - LogArea.Binding, - this, - "Could not convert FallbackValue {FallbackValue} to {Type}", + if (TypeUtilities.TryConvert( + type, _fallbackValue, - type); + 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); + else + { + _inner.SetValue(converted, _priority); + } } } } From 0b0ca8b0cb6e9a4571eeb1408fd3597de9e8ffcd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 Aug 2016 13:42:37 +0200 Subject: [PATCH 08/60] Fix MemberSelector. --- .../Templates/MemberSelector.cs | 45 ++++--------------- .../Properties/AssemblyInfo.cs | 1 - 2 files changed, 9 insertions(+), 37 deletions(-) diff --git a/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs b/src/Markup/Avalonia.Markup.Xaml/Templates/MemberSelector.cs index 7e9d7c00b8..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,48 +21,22 @@ namespace Avalonia.Markup.Xaml.Templates if (_memberName != value) { _memberName = value; - _expressionNode = null; - _memberValueNode = null; } } } public object Select(object o) { - throw new NotImplementedException(); - ////if (string.IsNullOrEmpty(MemberName)) - ////{ - //// 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 (string.IsNullOrEmpty(MemberName)) + { + return o; + } - ////if (result == AvaloniaProperty.UnsetValue) - ////{ - //// return null; - ////} - ////else if (result is BindingNotification) - ////{ - //// 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/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 From 0d4a613580be7dc5a99899d643e7b506e494f374 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 Aug 2016 14:30:25 +0200 Subject: [PATCH 09/60] Updated some docs. And simplified a bit of failing test code. --- src/Avalonia.Base/AvaloniaObject.cs | 4 ++++ src/Avalonia.Base/PriorityValue.cs | 1 - .../Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs | 9 +++++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index a1da97e824..4046915986 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -228,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); diff --git a/src/Avalonia.Base/PriorityValue.cs b/src/Avalonia.Base/PriorityValue.cs index 715fca3915..0dd18d852a 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. /// diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs index 7d8528c5d7..ed826f64e4 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs @@ -170,11 +170,16 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data }; 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.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; From 0c2057e458f91db75de5c7ea0f079f71baddd265 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 Aug 2016 21:50:11 +0200 Subject: [PATCH 10/60] Fix failing tests, add other tests. Also use logical parent's DataContext as base for DataContext bindings instead of visual parent's. Was previously in error. --- .../Avalonia.Markup.Xaml/Data/Binding.cs | 15 ++- .../Avalonia.Markup/Data/ExpressionNode.cs | 2 +- .../Data/ExpressionObserver.cs | 7 +- .../ExpressionObserverTests_DataValidation.cs | 5 +- .../Data/ExpressionObserverTests_Property.cs | 99 ++++++++++++++++--- .../Data/BindingTests.cs | 8 +- 6 files changed, 114 insertions(+), 22 deletions(-) diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index ec60695374..55f2baf04e 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs @@ -227,10 +227,7 @@ namespace Avalonia.Markup.Xaml.Data else { return new ExpressionObserver( - target.GetObservable(Visual.VisualParentProperty) - .OfType() - .Select(x => x.GetObservable(Control.DataContextProperty)) - .Switch(), + GetParentDataContext(target), path, EnableValidation); } @@ -272,6 +269,16 @@ namespace Avalonia.Markup.Xaml.Data return result; } + private IObservable GetParentDataContext(IAvaloniaObject target) + { + return target.GetObservable(Control.ParentProperty) + .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/Data/ExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs index 905cdcd713..75c4422962 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -178,7 +178,7 @@ namespace Avalonia.Markup.Data private BindingNotification TargetNullNotification() { // TODO: Work out a way to give a more useful error message here. - return new BindingNotification(new NullReferenceException(), BindingErrorType.Error); + return new BindingNotification(new NullReferenceException(), BindingErrorType.Error, AvaloniaProperty.UnsetValue); } } } diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index a29f36f899..358464a847 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -66,6 +66,11 @@ namespace Avalonia.Markup.Data { Contract.Requires(expression != null); + if (root == AvaloniaProperty.UnsetValue) + { + root = null; + } + Expression = expression; _node = Parse(expression, enableDataValidation); _root = new WeakReference(root); @@ -199,7 +204,7 @@ namespace Avalonia.Markup.Data if (observable != null) { return observable.Subscribe( - x => _node.Target = new WeakReference(x), + x => _node.Target = new WeakReference(x != AvaloniaProperty.UnsetValue ? x : null), _ => _finished.OnNext(Unit.Default), () => _finished.OnNext(Unit.Default)); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index 8789862b3a..cdcaeda4cc 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -142,7 +142,10 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal(new[] { - new BindingNotification(new NullReferenceException(), BindingErrorType.Error), + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), }, result); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index 043e85cae3..887879b164 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -57,6 +57,66 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal("foo", result); } + [Fact] + public async void Should_Return_BindingNotification_Error_For_Root_Null() + { + var data = new Class3 { Foo = "foo" }; + var target = new ExpressionObserver(default(object), "Foo"); + var result = await target.Take(1); + + Assert.Equal( + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + result); + } + + [Fact] + public async void Should_Return_BindingNotification_Error_For_Root_UnsetValue() + { + var data = new Class3 { Foo = "foo" }; + var target = new ExpressionObserver(AvaloniaProperty.UnsetValue, "Foo"); + var result = await target.Take(1); + + Assert.Equal( + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + result); + } + + [Fact] + public async void Should_Return_BindingNotification_Error_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( + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + result); + } + + [Fact] + public async void Should_Return_BindingNotification_Error_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( + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + result); + } + [Fact] public async void Should_Get_Simple_Property_Chain() { @@ -87,9 +147,10 @@ namespace Avalonia.Markup.UnitTests.Data Assert.IsType(result); - var error = result as BindingNotification; - Assert.IsType(error.Error); - Assert.Equal("Could not find CLR property 'Baz' on '1'", error.Error.Message); + Assert.Equal( + new BindingNotification( + new MissingMemberException("Could not find CLR property 'Baz' on '1'"), BindingErrorType.Error), + result); } [Fact] @@ -101,12 +162,15 @@ namespace Avalonia.Markup.UnitTests.Data target.Subscribe(x => result.Add(x)); - Assert.Equal(1, result.Count); - Assert.IsType(result[0]); - - var error = result[0] as BindingNotification; - Assert.IsType(error.Error); - Assert.Equal("Object reference not set to an instance of an object.", error.Error.Message); + Assert.Equal( + new[] + { + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + }, + result); } [Fact] @@ -219,7 +283,10 @@ namespace Avalonia.Markup.UnitTests.Data new object[] { "bar", - new BindingNotification(new NullReferenceException(), BindingErrorType.Error), + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), "baz" }, result); @@ -388,7 +455,12 @@ namespace Avalonia.Markup.UnitTests.Data var target = new ExpressionObserver((object)null, "Foo"); var result = await target.Take(1); - Assert.Equal(new BindingNotification(new NullReferenceException(), BindingErrorType.Error), result); + Assert.Equal( + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), + result); } [Fact] @@ -412,7 +484,10 @@ namespace Avalonia.Markup.UnitTests.Data { "foo", "bar", - new BindingNotification(new NullReferenceException(), BindingErrorType.Error) + new BindingNotification( + new NullReferenceException(), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue), }, result); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs index ed826f64e4..4ea281a79c 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; @@ -272,7 +274,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 @@ -299,12 +301,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; From 4fe2a3d86cd363207139f2642fbca5d900b86c49 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 Aug 2016 22:07:10 +0200 Subject: [PATCH 11/60] Removed vestiges of old data validation. --- src/Avalonia.Base/AvaloniaObject.cs | 29 ------------------- src/Avalonia.Base/IPriorityValueOwner.cs | 7 ----- src/Avalonia.Base/PriorityBindingEntry.cs | 14 ++++----- src/Avalonia.Base/PriorityLevel.cs | 11 ------- src/Avalonia.Base/PriorityValue.cs | 10 ------- src/Avalonia.Controls/Control.cs | 8 ----- src/Avalonia.Controls/TextBox.cs | 8 ----- .../Data/BindingTests_Validation.cs | 8 ----- 8 files changed, 5 insertions(+), 90 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 4046915986..469ea573c6 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -352,7 +352,6 @@ namespace Avalonia GetDescription(source)); IDisposable subscription = null; - IDisposable validationSubcription = null; if (_directBindings == null) { @@ -363,15 +362,11 @@ namespace Avalonia .Select(x => CastOrDefault(x, property.PropertyType)) .Do(_ => { }, () => _directBindings.Remove(subscription)) .Subscribe(x => DirectBindingSet(property, x)); - validationSubcription = source - .OfType() - .Subscribe(x => DataValidationChanged(property, x)); _directBindings.Add(subscription); return Disposable.Create(() => { - validationSubcription.Dispose(); subscription.Dispose(); _directBindings.Remove(subscription); }); @@ -466,30 +461,6 @@ namespace Avalonia } } - /// - void IPriorityValueOwner.DataValidationChanged(PriorityValue sender, BindingNotification status) - { - 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, BindingNotification status) - { - } - - /// - /// Updates the validation status of the current object. - /// - /// The new validation status. - protected void UpdateValidationState(BindingNotification status) - { - } - /// Delegate[] IAvaloniaObjectDebug.GetPropertyChangedSubscribers() { diff --git a/src/Avalonia.Base/IPriorityValueOwner.cs b/src/Avalonia.Base/IPriorityValueOwner.cs index b5a0c0abc1..2483739d54 100644 --- a/src/Avalonia.Base/IPriorityValueOwner.cs +++ b/src/Avalonia.Base/IPriorityValueOwner.cs @@ -17,12 +17,5 @@ namespace Avalonia /// The old value. /// The new value. void Changed(PriorityValue sender, object oldValue, object newValue); - - /// - /// Called when the validation state of a changes. - /// - /// The source of the change. - /// The validation status. - void DataValidationChanged(PriorityValue sender, BindingNotification status); } } diff --git a/src/Avalonia.Base/PriorityBindingEntry.cs b/src/Avalonia.Base/PriorityBindingEntry.cs index 85c8c7e55c..580b593666 100644 --- a/src/Avalonia.Base/PriorityBindingEntry.cs +++ b/src/Avalonia.Base/PriorityBindingEntry.cs @@ -97,20 +97,16 @@ namespace Avalonia if (notification != null) { - if (notification.ErrorType == BindingErrorType.Error) - { - _owner.Error(this, notification); - } - else if (notification.ErrorType == BindingErrorType.DataValidationError) - { - _owner.Validation(this, notification); - } - if (notification.HasValue) { Value = notification.Value; _owner.Changed(this); } + + if (notification.ErrorType != BindingErrorType.None) + { + _owner.Error(this, notification); + } } else { diff --git a/src/Avalonia.Base/PriorityLevel.cs b/src/Avalonia.Base/PriorityLevel.cs index b056dd38b7..122a6df821 100644 --- a/src/Avalonia.Base/PriorityLevel.cs +++ b/src/Avalonia.Base/PriorityLevel.cs @@ -164,17 +164,6 @@ namespace Avalonia _owner.LevelError(this, error); } - /// - /// Invoked when an entry in reports validation status. - /// - /// The entry that completed. - /// The validation status. - public void Validation(PriorityBindingEntry entry, BindingNotification 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 0dd18d852a..8673ab5f44 100644 --- a/src/Avalonia.Base/PriorityValue.cs +++ b/src/Avalonia.Base/PriorityValue.cs @@ -178,16 +178,6 @@ 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, BindingNotification validationStatus) - { - _owner.DataValidationChanged(this, validationStatus); - } - /// /// Called when a priority level encounters an error. /// diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index dba221c159..82212a17fe 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, BindingNotification status) - { - base.DataValidationChanged(property, status); - ////ValidationStatus.UpdateValidationStatus(status); - } - /// /// Sets the control's logical parent. /// diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index be8df58ba2..58adb8106f 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -235,14 +235,6 @@ namespace Avalonia.Controls HandleTextInput(e.Text); } - protected override void DataValidationChanged(AvaloniaProperty property, BindingNotification status) - { - if (property == TextProperty) - { - UpdateValidationState(status); - } - } - private void HandleTextInput(string input) { if (!IsReadOnly) diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs index d0adb1e03f..82ea33257e 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -124,14 +124,6 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data SetValue(ValidationTestProperty, value); } } - - protected override void DataValidationChanged(AvaloniaProperty property, BindingNotification status) - { - if (property == ValidationTestProperty) - { - UpdateValidationState(status); - } - } } private class ValidationTestModel From 06b0d15fc27e4d87e607d024268fadfe2ad017c9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 Aug 2016 23:12:10 +0200 Subject: [PATCH 12/60] Surface BindingNotifications in AvaloniaObject. --- src/Avalonia.Base/AvaloniaObject.cs | 31 ++- src/Avalonia.Base/AvaloniaProperty.cs | 22 +- src/Avalonia.Base/Data/BindingNotification.cs | 57 +++-- src/Avalonia.Base/DirectPropertyMetadata`1.cs | 8 +- src/Avalonia.Base/IPriorityValueOwner.cs | 8 + src/Avalonia.Base/PriorityValue.cs | 22 +- src/Avalonia.Base/PropertyMetadata.cs | 19 +- src/Avalonia.Base/StyledPropertyMetadata`1.cs | 8 +- .../Data/BindingTests_Validation.cs | 232 +++++++++++------- 9 files changed, 292 insertions(+), 115 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 469ea573c6..1ac534ce5e 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -461,6 +461,12 @@ namespace Avalonia } } + /// + void IPriorityValueOwner.BindingNotificationReceived(PriorityValue sender, BindingNotification notification) + { + BindingNotificationReceived(sender.Property, notification); + } + /// Delegate[] IAvaloniaObjectDebug.GetPropertyChangedSubscribers() { @@ -492,6 +498,18 @@ namespace Avalonia }); } + /// + /// Occurs when a is received for a property which has + /// data validation enabled. + /// + /// The property. + /// The binding notification. + protected virtual void BindingNotificationReceived( + AvaloniaProperty property, + BindingNotification notification) + { + } + /// /// Called when a avalonia property changes on the object. /// @@ -580,15 +598,20 @@ namespace Avalonia /// The cast value, or a . private static object CastOrDefault(object value, Type type) { - var error = value as BindingNotification; + var notification = value as BindingNotification; - if (error == null) + if (notification == null) { return TypeUtilities.CastOrDefault(value, type); } else { - return error; + if (notification.HasValue) + { + notification.Value = TypeUtilities.CastOrDefault(value, type); + } + + return notification; } } @@ -637,6 +660,8 @@ namespace Avalonia SetValue(property, notification.Value); } + BindingNotificationReceived(property, notification); + if (notification.ErrorType == BindingErrorType.Error) { Logger.Error( diff --git a/src/Avalonia.Base/AvaloniaProperty.cs b/src/Avalonia.Base/AvaloniaProperty.cs index 0f5500b116..6374042aab 100644 --- a/src/Avalonia.Base/AvaloniaProperty.cs +++ b/src/Avalonia.Base/AvaloniaProperty.cs @@ -251,6 +251,9 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. + /// + /// Whether the property is interested in data validation. + /// /// /// A method that gets called before and after the property starts being notified on an /// object; the bool argument will be true before and false afterwards. This callback is @@ -263,6 +266,7 @@ namespace Avalonia bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, Func validate = null, + bool enableDataValidation = false, Action notifying = null) where TOwner : IAvaloniaObject { @@ -294,13 +298,17 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. + /// + /// Whether the property is interested in data validation. + /// /// A public static AttachedProperty RegisterAttached( string name, TValue defaultValue = default(TValue), bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, - Func validate = null) + Func validate = null, + bool enableDataValidation = false) where THost : IAvaloniaObject { Contract.Requires(name != null); @@ -326,6 +334,9 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. + /// + /// Whether the property is interested in data validation. + /// /// A public static AttachedProperty RegisterAttached( string name, @@ -333,7 +344,8 @@ namespace Avalonia TValue defaultValue = default(TValue), bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, - Func validate = null) + Func validate = null, + bool enableDataValidation = false) where THost : IAvaloniaObject { Contract.Requires(name != null); @@ -360,13 +372,17 @@ 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); diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index 992e4093fc..b1c98c72d0 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -87,7 +87,7 @@ namespace Avalonia.Data /// Gets the value that should be passed to the target when /// is true. /// - public object Value { get; } + public object Value { get; set; } /// /// Gets a value indicating whether should be pushed to the target. @@ -97,13 +97,19 @@ namespace Avalonia.Data /// /// Gets the error that occurred on the source, if any. /// - public Exception Error { get; } + public Exception Error { get; private set; } /// /// Gets the type of error that represents, if any. /// - public BindingErrorType ErrorType { get; } + public BindingErrorType ErrorType { get; private 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)) @@ -122,45 +128,68 @@ namespace Avalonia.Data (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); } + /// + /// 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(); } - public BindingNotification WithError(Exception e) + /// + /// Adds an error to the . + /// + /// The error to add. + /// The error type. + public void AddError(Exception e, BindingErrorType type) { - if (e == null) - { - return this; - } + Contract.Requires(e != null); + Contract.Requires(type != BindingErrorType.None); if (Error != null) { - e = new AggregateException(Error, e); + Error = new AggregateException(Error, e); } - - if (HasValue) + else { - return new BindingNotification(e, BindingErrorType.Error, Value); + Error = e; } - else + + if (type == BindingErrorType.Error || ErrorType == BindingErrorType.Error) { - return new BindingNotification(e, BindingErrorType.Error, Value); + ErrorType = BindingErrorType.Error; } } diff --git a/src/Avalonia.Base/DirectPropertyMetadata`1.cs b/src/Avalonia.Base/DirectPropertyMetadata`1.cs index 69dac6e8e2..e446095c06 100644 --- a/src/Avalonia.Base/DirectPropertyMetadata`1.cs +++ b/src/Avalonia.Base/DirectPropertyMetadata`1.cs @@ -17,10 +17,14 @@ 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) - : base(defaultBindingMode) + BindingMode defaultBindingMode = BindingMode.Default, + bool enableDataValidation = false) + : base(defaultBindingMode, enableDataValidation) { UnsetValue = unsetValue; } diff --git a/src/Avalonia.Base/IPriorityValueOwner.cs b/src/Avalonia.Base/IPriorityValueOwner.cs index 2483739d54..57f98c0717 100644 --- a/src/Avalonia.Base/IPriorityValueOwner.cs +++ b/src/Avalonia.Base/IPriorityValueOwner.cs @@ -17,5 +17,13 @@ namespace Avalonia /// The old value. /// The new value. void Changed(PriorityValue sender, object oldValue, object newValue); + + /// + /// Called when a is received by a + /// . + /// + /// The source of the change. + /// The notification. + void BindingNotificationReceived(PriorityValue sender, BindingNotification notification); } } diff --git a/src/Avalonia.Base/PriorityValue.cs b/src/Avalonia.Base/PriorityValue.cs index 8673ab5f44..cfe6e8818d 100644 --- a/src/Avalonia.Base/PriorityValue.cs +++ b/src/Avalonia.Base/PriorityValue.cs @@ -237,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; @@ -250,7 +256,21 @@ namespace Avalonia ValuePriority = priority; _value = castValue; - _owner?.Changed(this, old, _value); + + if (notification?.HasValue == true) + { + notification.Value = 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..a4d17407f8 100644 --- a/src/Avalonia.Base/PropertyMetadata.cs +++ b/src/Avalonia.Base/PropertyMetadata.cs @@ -17,9 +17,15 @@ namespace Avalonia /// Initializes a new instance of the class. /// /// The default binding mode. - public PropertyMetadata(BindingMode defaultBindingMode = BindingMode.Default) + /// + /// Whether the property is interested in data validation. + /// + public PropertyMetadata( + BindingMode defaultBindingMode = BindingMode.Default, + bool enableDataValidation = false) { _defaultBindingMode = defaultBindingMode; + EnabledDataValidation = enableDataValidation; } /// @@ -34,6 +40,17 @@ namespace Avalonia } } + /// + /// 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 EnabledDataValidation { get; } + /// /// Merges the metadata with the base metadata. /// diff --git a/src/Avalonia.Base/StyledPropertyMetadata`1.cs b/src/Avalonia.Base/StyledPropertyMetadata`1.cs index ed01f1bc70..5982ca4506 100644 --- a/src/Avalonia.Base/StyledPropertyMetadata`1.cs +++ b/src/Avalonia.Base/StyledPropertyMetadata`1.cs @@ -18,11 +18,15 @@ namespace Avalonia /// The default value of the property. /// A validation function. /// The default binding mode. + /// + /// Whether the property is interested in data validation. + /// public StyledPropertyMetadata( TValue defaultValue = default(TValue), Func validate = null, - BindingMode defaultBindingMode = BindingMode.Default) - : base(defaultBindingMode) + BindingMode defaultBindingMode = BindingMode.Default, + bool enableDataValidation = false) + : base(defaultBindingMode, enableDataValidation) { DefaultValue = defaultValue; Validate = validate; diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs index 82ea33257e..835e65d9e5 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -1,7 +1,10 @@ using System; +using System.Collections; +using System.Collections.Generic; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Markup.Xaml.Data; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Markup.Xaml.UnitTests.Data @@ -9,124 +12,173 @@ 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.True(false); - //Assert.False(target.ValidationStatus.IsValid); + Assert.Empty(target.Notifications); } [Fact] - public void Enabled_Validation_Should_Trigger_Validation_Change_On_Exception() + public void 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, - EnableValidation = true, + DataContext = source, + [!TestControl.ValidatedProperty] = new Binding(nameof(source.MustBePositive)), }; - target.Bind(TestControl.ValidationTestProperty, binding); + source.MustBePositive = 6; - target.ValidationTest = -5; - Assert.True(false); - //Assert.False(target.ValidationStatus.IsValid); + Assert.Equal( + new[] + { + new BindingNotification(5), + new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), + new BindingNotification(6), + }, + target.Notifications); } + //[Fact] + //public void Disabled_Validation_Should_Trigger_Validation_Change_On_Exception() + //{ + // var source = new ValidationTestModel { MustBePositive = 5 }; + // var target = new TestControl { DataContext = source }; + // var binding = new Binding + // { + // Path = nameof(source.MustBePositive), + // Mode = BindingMode.TwoWay, + + // // Even though EnableValidation = false, exception validation is enabled. + // EnableValidation = false, + // }; + + // target.Bind(TestControl.ValidationTestProperty, binding); + + // target.ValidationTest = -5; + + // Assert.True(false); + // //Assert.False(target.ValidationStatus.IsValid); + //} + + //[Fact] + //public void Enabled_Validation_Should_Trigger_Validation_Change_On_Exception() + //{ + // var source = new ValidationTestModel { MustBePositive = 5 }; + // var target = new TestControl { DataContext = source }; + // var binding = new Binding + // { + // Path = nameof(source.MustBePositive), + // Mode = BindingMode.TwoWay, + // EnableValidation = true, + // }; + + // target.Bind(TestControl.ValidationTestProperty, binding); + + // target.ValidationTest = -5; + // Assert.True(false); + // //Assert.False(target.ValidationStatus.IsValid); + //} + + + //[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, + // }; + + // control.Bind(TestControl.ValidationTestProperty, binding); + // control.DataContext = model; + // Assert.DoesNotContain(control.Classes, x => x == ":invalid"); + //} + + //[Fact] + //public void Failed_Validation_Should_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, + // }; + + // 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 }; + + // var binding = new Binding + // { + // 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"); + //} - [Fact] - public void Passed_Validation_Should_Not_Add_Invalid_Pseudo_Class() + private class TestControl : Control { - var control = new TestControl(); - var model = new ValidationTestModel { MustBePositive = 1 }; - var binding = new Binding - { - Path = nameof(model.MustBePositive), - Mode = BindingMode.TwoWay, - EnableValidation = true, - }; + public static readonly StyledProperty NonValidatedProperty = + AvaloniaProperty.Register( + nameof(Validated), + enableDataValidation: false); - control.Bind(TestControl.ValidationTestProperty, binding); - control.DataContext = model; - Assert.DoesNotContain(control.Classes, x => x == ":invalid"); - } + public static readonly StyledProperty ValidatedProperty = + AvaloniaProperty.Register( + nameof(Validated), + enableDataValidation: true); - [Fact] - public void Failed_Validation_Should_Add_Invalid_Pseudo_Class() - { - var control = new TestControl(); - var model = new ValidationTestModel { MustBePositive = 1 }; - var binding = new Binding + 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"); - } + get { return GetValue(ValidatedProperty); } + set { SetValue(ValidatedProperty, value); } + } - private class TestControl : Control - { - public static readonly StyledProperty ValidationTestProperty - = AvaloniaProperty.Register(nameof(ValidationTest), 1, defaultBindingMode: BindingMode.TwoWay); + public IList Notifications { get; } = new List(); - public int ValidationTest + protected override void BindingNotificationReceived(AvaloniaProperty property, BindingNotification notification) { - get - { - return GetValue(ValidationTestProperty); - } - set - { - SetValue(ValidationTestProperty, value); - } + Notifications.Add(notification); } } - private class ValidationTestModel + private class ValidationTestModel : NotifyingBase { private int mustBePositive; @@ -139,7 +191,9 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data { throw new ArgumentOutOfRangeException(nameof(value)); } + mustBePositive = value; + RaisePropertyChanged(); } } } From f4c57b169bc345a82c2fdf16c59cce17e7dea8aa Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 Aug 2016 00:08:26 +0200 Subject: [PATCH 13/60] Handle BindingNotifications in ExpressionSubject. --- src/Avalonia.Base/Data/BindingNotification.cs | 4 +- .../Avalonia.Markup/Data/ExpressionSubject.cs | 55 +++++++++++++------ src/Markup/Avalonia.Markup/IValueConverter.cs | 9 +-- .../Data/ExpressionSubjectTests.cs | 45 +++++++++++++-- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index b1c98c72d0..97f538d862 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -97,12 +97,12 @@ namespace Avalonia.Data /// /// Gets the error that occurred on the source, if any. /// - public Exception Error { get; private set; } + public Exception Error { get; set; } /// /// Gets the type of error that represents, if any. /// - public BindingErrorType ErrorType { get; private set; } + public BindingErrorType ErrorType { get; set; } /// /// Compares two instances of for equality. diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs index b26ec3402e..aae2b75dec 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs @@ -21,6 +21,7 @@ namespace Avalonia.Markup.Data 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. @@ -130,14 +131,18 @@ namespace Avalonia.Markup.Data } else if (converted is BindingNotification) { - var error = converted as BindingNotification; + var notification = converted as BindingNotification; - Logger.Error( - LogArea.Binding, - this, - "Error binding to {Expression}: {Message}", - _inner.Expression, - error.Error.Message); + if (notification.ErrorType == BindingErrorType.None) + { + throw new AvaloniaInternalException( + "IValueConverter should not return non-errored BindingNotification."); + } + + notification.Error = new InvalidCastException( + $"Error setting '{_inner.Expression}': {notification.Error.Message}"); + notification.ErrorType = BindingErrorType.Error; + _errors.OnNext(notification); if (_fallbackValue != AvaloniaProperty.UnsetValue) { @@ -171,19 +176,23 @@ namespace Avalonia.Markup.Data /// public IDisposable Subscribe(IObserver observer) { - return _inner.Select(ConvertValue).Subscribe(observer); + return _inner.Select(ConvertValue).Merge(_errors).Subscribe(observer); } private object ConvertValue(object value) { - var converted = - value as BindingNotification ?? - ////value as IValidationStatus ?? - Converter.Convert( - value, - _targetType, - ConverterParameter, - CultureInfo.CurrentUICulture); + var notification = value as BindingNotification; + + if (notification?.HasValue == true) + { + value = notification.Value; + } + + var converted = Converter.Convert( + value, + _targetType, + ConverterParameter, + CultureInfo.CurrentUICulture); if (_fallbackValue != AvaloniaProperty.UnsetValue && (converted == AvaloniaProperty.UnsetValue || @@ -211,7 +220,19 @@ namespace Avalonia.Markup.Data } } - return converted; + if (notification == null) + { + return converted; + } + else + { + if (notification.HasValue) + { + notification.Value = converted; + } + + return notification; + } } } } 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/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs index 449f402850..ce8a5b6d3d 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs @@ -10,6 +10,8 @@ using Avalonia.Data; using Avalonia.Markup.Data; using Xunit; using System.Threading; +using System.Collections.Generic; +using Avalonia.UnitTests; namespace Avalonia.Markup.UnitTests.Data { @@ -186,13 +188,48 @@ namespace Avalonia.Markup.UnitTests.Data converter.Verify(x => x.ConvertBack("bar", typeof(double), "foo", CultureInfo.CurrentUICulture)); } - private class Class1 : INotifyPropertyChanged + [Fact] + public void Should_Handle_DataValidation() { - public event PropertyChangedEventHandler PropertyChanged; + var data = new Class1 { DoubleValue = 5.6 }; + var converter = new Mock(); + var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue", true), typeof(string)); + var result = new List(); - public string StringValue { get; set; } + target.Subscribe(x => result.Add(x)); + target.OnNext(1.2); + target.OnNext("3.4"); + target.OnNext("bar"); - public double DoubleValue { get; set; } + Assert.Equal( + new[] + { + new BindingNotification("5.6"), + new BindingNotification("1.2"), + new BindingNotification("3.4"), + new BindingNotification( + new InvalidCastException("Error setting 'DoubleValue': Could not convert 'bar' to 'System.Double'"), + 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(); } + } } } } From 5a9371f786e08d6f698cba21298bc89235e1a658 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 Aug 2016 00:55:31 +0200 Subject: [PATCH 14/60] Pass EnableDataValidation to ExpressionObserver. --- src/Avalonia.Base/AvaloniaObjectExtensions.cs | 9 ++++-- src/Avalonia.Base/AvaloniaProperty.cs | 12 +++++--- src/Avalonia.Base/Data/IBinding.cs | 4 ++- src/Avalonia.Base/Data/IndexerBinding.cs | 6 +++- .../Avalonia.Markup.Xaml/Data/Binding.cs | 29 ++++++++++--------- .../Avalonia.Markup.Xaml/Data/MultiBinding.cs | 3 +- .../Data/StyleResourceBinding.cs | 3 +- .../MarkupExtensions/BindingExtension.cs | 2 -- .../TextBoxTests_ValidationState.cs | 6 ++-- .../Data/BindingTests_Validation.cs | 4 +-- .../Avalonia.Styling.UnitTests/SetterTests.cs | 2 +- 11 files changed, 48 insertions(+), 32 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObjectExtensions.cs b/src/Avalonia.Base/AvaloniaObjectExtensions.cs index 2751d8d5d5..4041573f8f 100644 --- a/src/Avalonia.Base/AvaloniaObjectExtensions.cs +++ b/src/Avalonia.Base/AvaloniaObjectExtensions.cs @@ -216,7 +216,11 @@ namespace Avalonia Contract.Requires(property != null); Contract.Requires(binding != null); - var result = binding.Initiate(target, property, anchor); + var result = binding.Initiate( + target, + property, + anchor, + property.GetMetadata(target.GetType()).EnabledDataValidation); if (result != null) { @@ -311,7 +315,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 6374042aab..f12c534aa8 100644 --- a/src/Avalonia.Base/AvaloniaProperty.cs +++ b/src/Avalonia.Base/AvaloniaProperty.cs @@ -275,7 +275,8 @@ namespace Avalonia var metadata = new StyledPropertyMetadata( defaultValue, validate: Cast(validate), - defaultBindingMode: defaultBindingMode); + defaultBindingMode: defaultBindingMode, + enableDataValidation: enableDataValidation); var result = new StyledProperty( name, @@ -316,7 +317,8 @@ namespace Avalonia var metadata = new StyledPropertyMetadata( defaultValue, validate: Cast(validate), - defaultBindingMode: defaultBindingMode); + defaultBindingMode: defaultBindingMode, + enableDataValidation: enableDataValidation); var result = new AttachedProperty(name, typeof(TOwner), metadata, inherits); AvaloniaPropertyRegistry.Instance.Register(typeof(THost), result); @@ -353,7 +355,8 @@ namespace Avalonia var metadata = new StyledPropertyMetadata( defaultValue, validate: Cast(validate), - defaultBindingMode: defaultBindingMode); + defaultBindingMode: defaultBindingMode, + enableDataValidation: enableDataValidation); var result = new AttachedProperty(name, ownerType, metadata, inherits); AvaloniaPropertyRegistry.Instance.Register(typeof(THost), result); @@ -389,7 +392,8 @@ namespace Avalonia 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/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/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/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index 55f2baf04e..9c6556b809 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs @@ -78,16 +78,12 @@ 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); @@ -105,7 +101,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 +109,8 @@ namespace Avalonia.Markup.Xaml.Data target, pathInfo.Path, targetProperty == Control.DataContextProperty, - anchor); + anchor, + enableDataValidation); } else if (RelativeSource.Mode == RelativeSourceMode.TemplatedParent) { @@ -197,7 +194,8 @@ namespace Avalonia.Markup.Xaml.Data IAvaloniaObject target, string path, bool targetIsDataContext, - object anchor) + object anchor, + bool enableDataValidation) { Contract.Requires(target != null); @@ -220,7 +218,7 @@ namespace Avalonia.Markup.Xaml.Data () => target.GetValue(Control.DataContextProperty), path, update, - EnableValidation); + enableDataValidation); return result; } @@ -229,7 +227,7 @@ namespace Avalonia.Markup.Xaml.Data return new ExpressionObserver( GetParentDataContext(target), path, - EnableValidation); + enableDataValidation); } } @@ -240,15 +238,18 @@ namespace Avalonia.Markup.Xaml.Data var result = new ExpressionObserver( ControlLocator.Track(target, elementName), path, - EnableValidation); + false); 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( 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/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs index 1036b70d30..34de11cc3b 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs @@ -21,7 +21,7 @@ namespace Avalonia.Controls.UnitTests var target = new TextBox(); var binding = new Binding(nameof(ExceptionTest.LessThan10)); binding.Source = new ExceptionTest(); - binding.EnableValidation = true; + ////binding.EnableValidation = true; target.Bind(TextBox.TextProperty, binding); Assert.True(false); @@ -41,7 +41,7 @@ namespace Avalonia.Controls.UnitTests var target = new TextBox(); var binding = new Binding(nameof(ExceptionTest.LessThan10)); binding.Source = new ExceptionTest(); - binding.EnableValidation = true; + ////binding.EnableValidation = true; target.Bind(TextBox.TextProperty, binding); Assert.True(false); @@ -61,7 +61,7 @@ namespace Avalonia.Controls.UnitTests var target = new TextBox(); var binding = new Binding(nameof(ExceptionTest.LessThan10)); binding.Source = new IndeiTest(); - binding.EnableValidation = true; + ////binding.EnableValidation = true; target.Bind(TextBox.TextProperty, binding); Assert.True(false); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs index 835e65d9e5..f128a67839 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -25,15 +25,15 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data } [Fact] - public void Validated_Property_Does_Not_Receive_BindingNotifications() + public void Validated_Property_Receives_BindingNotifications() { var source = new ValidationTestModel { MustBePositive = 5 }; var target = new TestControl { DataContext = source, - [!TestControl.ValidatedProperty] = new Binding(nameof(source.MustBePositive)), }; + target.Bind(TestControl.ValidatedProperty, new Binding(nameof(source.MustBePositive))); source.MustBePositive = 6; Assert.Equal( 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); From f618103518cbe9c1550f7af9a59163fcfc0689da Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 Aug 2016 18:05:18 +0200 Subject: [PATCH 15/60] Fix casting of BindingNotification value. --- src/Avalonia.Base/AvaloniaObject.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 1ac534ce5e..72f70cab2a 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -608,7 +608,7 @@ namespace Avalonia { if (notification.HasValue) { - notification.Value = TypeUtilities.CastOrDefault(value, type); + notification.Value = TypeUtilities.CastOrDefault(notification.Value, type); } return notification; From 74e870333b5d4e1d41ece86ae62fa4c90b0dca2b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 Aug 2016 19:45:45 +0200 Subject: [PATCH 16/60] Correctly convert BindingNotifications. In ExpressionSubject. --- src/Avalonia.Base/Data/BindingNotification.cs | 2 +- .../Avalonia.Markup/Data/ExpressionSubject.cs | 135 +++++++++++++----- .../AvaloniaObjectTests_Direct.cs | 1 - .../Data/ExpressionSubjectTests.cs | 86 +++++++++++ 4 files changed, 187 insertions(+), 37 deletions(-) diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index 97f538d862..9351443e03 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -92,7 +92,7 @@ namespace Avalonia.Data /// /// Gets a value indicating whether should be pushed to the target. /// - public bool HasValue { get; } + public bool HasValue { get; set; } /// /// Gets the error that occurred on the source, if any. diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs index aae2b75dec..5a7bb80e87 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs @@ -183,56 +183,121 @@ namespace Avalonia.Markup.Data { var notification = value as BindingNotification; - if (notification?.HasValue == true) + if (notification == null) { - value = notification.Value; - } + var converted = Converter.Convert( + value, + _targetType, + ConverterParameter, + CultureInfo.CurrentUICulture); - var converted = Converter.Convert( - value, - _targetType, - ConverterParameter, - CultureInfo.CurrentUICulture); + notification = converted as BindingNotification; - if (_fallbackValue != AvaloniaProperty.UnsetValue && - (converted == AvaloniaProperty.UnsetValue || - converted is BindingNotification)) - { - var error = converted as BindingNotification; - - if (TypeUtilities.TryConvert( - _targetType, - _fallbackValue, - CultureInfo.InvariantCulture, - out converted)) + if (notification?.ErrorType == BindingErrorType.None) { - if (error != null) - { - converted = new BindingNotification(error.Error, BindingErrorType.Error, converted); - } + converted = notification.Value; } - else + + if (_fallbackValue != AvaloniaProperty.UnsetValue && + (converted == AvaloniaProperty.UnsetValue || converted is BindingNotification)) { - converted = new BindingNotification( - new InvalidCastException( - $"Could not convert FallbackValue '{_fallbackValue}' to '{_targetType}'"), - BindingErrorType.Error); + var fallback = ConvertFallback(); + converted = Merge(converted, fallback); } + + return converted; } + else + { + return ConvertValue(notification); + } + } - if (notification == null) + private BindingNotification ConvertValue(BindingNotification notification) + { + if (notification.HasValue) { - return converted; + 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 BindingNotification Merge(object a, BindingNotification b) + { + var an = a as BindingNotification; + + if (an != null) { - if (notification.HasValue) - { - notification.Value = converted; - } + Merge(an, b); + return an; + } + else + { + return b; + } + } - return notification; + private BindingNotification Merge(BindingNotification a, object b) + { + var bn = b as BindingNotification; + + if (bn != null) + { + Merge(a, bn); } + else + { + a.Value = b; + a.HasValue = true; + } + + return a; + } + + private BindingNotification Merge(BindingNotification a, BindingNotification b) + { + a.Value = b.Value; + a.HasValue = b.HasValue; + + if (b.Error != null) + { + a.AddError(b.Error, b.ErrorType); + } + + return a; } } } diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs index cc3e5c4052..51d5d19834 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() { diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs index ce8a5b6d3d..5498926fe4 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs @@ -108,6 +108,92 @@ namespace Avalonia.Markup.UnitTests.Data 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 ExpressionSubject( + new ExpressionObserver(data, "StringValue"), + typeof(int), + 42, + DefaultValueConverter.Instance); + var result = await target.Take(1); + + Assert.Equal( + new BindingNotification( + new InvalidCastException("Could not convert 'foo' to 'System.Int32'"), + 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 ExpressionSubject( + new ExpressionObserver(data, "StringValue", true), + typeof(int), + 42, + DefaultValueConverter.Instance); + var result = await target.Take(1); + + Assert.Equal( + new BindingNotification( + new InvalidCastException("Could not convert 'foo' to 'System.Int32'"), + 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 ExpressionSubject( + 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 ExpressionSubject( + 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() { From 92ebb7f6d8b1485313dbe101d3b3ed8060db87a0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 Aug 2016 19:55:04 +0200 Subject: [PATCH 17/60] Log meaningful message for AggregateException. --- src/Avalonia.Base/AvaloniaObject.cs | 2 +- .../Utilities/ExceptionUtilities.cs | 23 +++++++++++++++++++ .../Xaml/ControlBindingTests.cs | 3 ++- 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 src/Avalonia.Base/Utilities/ExceptionUtilities.cs diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 72f70cab2a..83b0d4e533 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -670,7 +670,7 @@ namespace Avalonia "Error binding to {Target}.{Property}: {Message}", this, property, - notification.Error.Message); + ExceptionUtilities.GetMessage(notification.Error)); } } } 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/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs index df62a1ed41..029285341b 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs @@ -43,7 +43,8 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml pv.Length == 3 && pv[0] is ProgressBar && object.ReferenceEquals(pv[1], ProgressBar.ValueProperty) && - (string)pv[2] == "Could not convert FallbackValue 'bar' to 'System.Double'") + (string)pv[2] == "Object reference not set to an instance of an object. | " + + "Could not convert FallbackValue 'bar' to 'System.Double'") { called = true; } From f720a7d66f472b7e9e174d4c19dc86c8ecf0d7c6 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 Aug 2016 20:14:23 +0200 Subject: [PATCH 18/60] Test that BindingNotificationReceived is called. For direct properties. --- .../Avalonia.Markup.Xaml/Data/Binding.cs | 4 +- .../Data/BindingTests_Validation.cs | 143 +++++------------- 2 files changed, 37 insertions(+), 110 deletions(-) diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index 9c6556b809..56882ddcbe 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; } /// diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs index f128a67839..8759cb42c5 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Markup.Xaml.Data; @@ -25,7 +26,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data } [Fact] - public void Validated_Property_Receives_BindingNotifications() + public void Validated_Direct_Property_Receives_BindingNotifications() { var source = new ValidationTestModel { MustBePositive = 5 }; var target = new TestControl @@ -33,119 +34,25 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data DataContext = source, }; - target.Bind(TestControl.ValidatedProperty, new Binding(nameof(source.MustBePositive))); - source.MustBePositive = 6; + target.Bind( + TestControl.ValidatedDirectProperty, + new Binding(nameof(source.MustBePositive), BindingMode.TwoWay)); + + target.ValidatedDirect = 6; + target.ValidatedDirect = -1; + target.ValidatedDirect = 7; Assert.Equal( new[] { new BindingNotification(5), - new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), new BindingNotification(6), + new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), + new BindingNotification(7), }, - target.Notifications); + target.Notifications.AsEnumerable()); } - //[Fact] - //public void Disabled_Validation_Should_Trigger_Validation_Change_On_Exception() - //{ - // var source = new ValidationTestModel { MustBePositive = 5 }; - // var target = new TestControl { DataContext = source }; - // var binding = new Binding - // { - // Path = nameof(source.MustBePositive), - // Mode = BindingMode.TwoWay, - - // // Even though EnableValidation = false, exception validation is enabled. - // EnableValidation = false, - // }; - - // target.Bind(TestControl.ValidationTestProperty, binding); - - // target.ValidationTest = -5; - - // Assert.True(false); - // //Assert.False(target.ValidationStatus.IsValid); - //} - - //[Fact] - //public void Enabled_Validation_Should_Trigger_Validation_Change_On_Exception() - //{ - // var source = new ValidationTestModel { MustBePositive = 5 }; - // var target = new TestControl { DataContext = source }; - // var binding = new Binding - // { - // Path = nameof(source.MustBePositive), - // Mode = BindingMode.TwoWay, - // EnableValidation = true, - // }; - - // target.Bind(TestControl.ValidationTestProperty, binding); - - // target.ValidationTest = -5; - // Assert.True(false); - // //Assert.False(target.ValidationStatus.IsValid); - //} - - - //[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, - // }; - - // control.Bind(TestControl.ValidationTestProperty, binding); - // control.DataContext = model; - // Assert.DoesNotContain(control.Classes, x => x == ":invalid"); - //} - - //[Fact] - //public void Failed_Validation_Should_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, - // }; - - // 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 }; - - // var binding = new Binding - // { - // 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 NonValidatedProperty = @@ -158,6 +65,15 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data 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 { get { return GetValue(NonValidatedProperty); } @@ -170,6 +86,12 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data set { SetValue(ValidatedProperty, value); } } + public int ValidatedDirect + { + get { return _direct; } + set { SetAndRaise(ValidatedDirectProperty, ref _direct, value); } + } + public IList Notifications { get; } = new List(); protected override void BindingNotificationReceived(AvaloniaProperty property, BindingNotification notification) @@ -180,11 +102,11 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data private class ValidationTestModel : NotifyingBase { - private int mustBePositive; + private int _mustBePositive; public int MustBePositive { - get { return mustBePositive; } + get { return _mustBePositive; } set { if (value <= 0) @@ -192,8 +114,11 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data throw new ArgumentOutOfRangeException(nameof(value)); } - mustBePositive = value; - RaisePropertyChanged(); + if (_mustBePositive != value) + { + _mustBePositive = value; + RaisePropertyChanged(); + } } } } From 736d290f19c55e61db05231669d073ff7265f123 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 Aug 2016 20:26:43 +0200 Subject: [PATCH 19/60] Fixed typo. --- src/Avalonia.Base/AvaloniaObjectExtensions.cs | 2 +- src/Avalonia.Base/PropertyMetadata.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObjectExtensions.cs b/src/Avalonia.Base/AvaloniaObjectExtensions.cs index 4041573f8f..77da9d5680 100644 --- a/src/Avalonia.Base/AvaloniaObjectExtensions.cs +++ b/src/Avalonia.Base/AvaloniaObjectExtensions.cs @@ -220,7 +220,7 @@ namespace Avalonia target, property, anchor, - property.GetMetadata(target.GetType()).EnabledDataValidation); + property.GetMetadata(target.GetType()).EnableDataValidation); if (result != null) { diff --git a/src/Avalonia.Base/PropertyMetadata.cs b/src/Avalonia.Base/PropertyMetadata.cs index a4d17407f8..3fe0e85b0d 100644 --- a/src/Avalonia.Base/PropertyMetadata.cs +++ b/src/Avalonia.Base/PropertyMetadata.cs @@ -25,7 +25,7 @@ namespace Avalonia bool enableDataValidation = false) { _defaultBindingMode = defaultBindingMode; - EnabledDataValidation = enableDataValidation; + EnableDataValidation = enableDataValidation; } /// @@ -49,7 +49,7 @@ namespace Avalonia /// 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 EnabledDataValidation { get; } + public bool EnableDataValidation { get; } /// /// Merges the metadata with the base metadata. From 57e646583fcd540a9b2795ee5e07700e241ea4f7 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 Aug 2016 20:49:32 +0200 Subject: [PATCH 20/60] Only send BindingNotifications on error. Assume that a normal value when received by a property with data validation enabled means no error. --- src/Avalonia.Base/AvaloniaObject.cs | 38 ++++++++++--------- .../Data/Plugins/DataValidatiorBase.cs | 6 +-- .../Data/Plugins/IndeiValidationPlugin.cs | 8 ++-- .../ExpressionObserverTests_DataValidation.cs | 12 +++--- .../ExpressionObserverTests_Observable.cs | 2 +- .../Data/ExpressionSubjectTests.cs | 8 ++-- .../Plugins/ExceptionValidationPluginTests.cs | 10 ++--- .../Plugins/IndeiValidationPluginTests.cs | 12 +++--- .../Data/BindingTests_Validation.cs | 8 ++-- 9 files changed, 53 insertions(+), 51 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 83b0d4e533..be7a14dcf9 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -464,7 +464,7 @@ namespace Avalonia /// void IPriorityValueOwner.BindingNotificationReceived(PriorityValue sender, BindingNotification notification) { - BindingNotificationReceived(sender.Property, notification); + UpdateDataValidation(sender.Property, notification); } /// @@ -499,14 +499,16 @@ namespace Avalonia } /// - /// Occurs when a is received for a property which has - /// data validation enabled. + /// Called to update the validation state for properties for which data validation is + /// enabled. /// /// The property. - /// The binding notification. - protected virtual void BindingNotificationReceived( + /// + /// The new validation status. A value of null indicates no validation error. + /// + protected virtual void UpdateDataValidation( AvaloniaProperty property, - BindingNotification notification) + BindingNotification status) { } @@ -647,20 +649,12 @@ namespace Avalonia /// private void DirectBindingSet(AvaloniaProperty property, object value) { + var validated = property.GetMetadata(GetType()).EnableDataValidation; var notification = value as BindingNotification; - if (notification == null) - { - SetValue(property, value); - } - else + if (notification != null) { - if (notification.HasValue) - { - SetValue(property, notification.Value); - } - - BindingNotificationReceived(property, notification); + value = notification.Value; if (notification.ErrorType == BindingErrorType.Error) { @@ -673,6 +667,16 @@ namespace Avalonia ExceptionUtilities.GetMessage(notification.Error)); } } + + if (notification?.HasValue != false) + { + SetValue(property, value); + } + + if (validated) + { + UpdateDataValidation(property, notification); + } } /// diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs b/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs index 95d269f437..9b387456ca 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs @@ -68,13 +68,11 @@ namespace Avalonia.Markup.Data.Plugins /// /// 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. + /// Notifies the observer that the value has changed. /// protected virtual void InnerValueChanged(object value) { - var notification = value as BindingNotification ?? new BindingNotification(value); - Observer.OnNext(notification); + Observer.OnNext(value); } } } \ No newline at end of file diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs index 8fb2568f30..561bd66db3 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs @@ -40,7 +40,7 @@ namespace Avalonia.Markup.Data.Plugins { if (e.PropertyName == _name || string.IsNullOrEmpty(e.PropertyName)) { - Observer.OnNext(CreateBindingNotification(Value)); + Observer.OnNext(BuildResult(Value)); } } @@ -76,10 +76,10 @@ namespace Avalonia.Markup.Data.Plugins protected override void InnerValueChanged(object value) { - base.InnerValueChanged(CreateBindingNotification(value)); + base.InnerValueChanged(BuildResult(value)); } - private BindingNotification CreateBindingNotification(object value) + private object BuildResult(object value) { var target = (INotifyDataErrorInfo)_reference.Target; @@ -98,7 +98,7 @@ namespace Avalonia.Markup.Data.Plugins } } - return new BindingNotification(value); + return value; } private Exception GenerateException(IList errors) diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index cdcaeda4cc..a363d69486 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -81,16 +81,16 @@ namespace Avalonia.Markup.UnitTests.Data observer.SetValue("foo"); observer.SetValue(5); - Assert.Equal(new[] + Assert.Equal(new object[] { - new BindingNotification(0), + 0, // Value is notified twice as ErrorsChanged is always called by IndeiTest. - new BindingNotification(5), - new BindingNotification(5), + 5, + 5, // Value is first signalled without an error as validation hasn't been updated. - new BindingNotification(-5), + -5, new BindingNotification(new Exception("Must be positive"), BindingErrorType.DataValidationError, -5), // Exception is thrown by trying to set value to "foo". @@ -100,7 +100,7 @@ namespace Avalonia.Markup.UnitTests.Data // Value is set then validation is updated. new BindingNotification(new Exception("Must be positive"), BindingErrorType.DataValidationError, 5), - new BindingNotification(5), + 5, }, result); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs index 3263aaace2..3b915f97a8 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs @@ -85,7 +85,7 @@ namespace Avalonia.Markup.UnitTests.Data data.Next.OnNext(new Class2("foo")); sync.ExecutePostedCallbacks(); - Assert.Equal(new[] { new BindingNotification("foo") }, result); + Assert.Equal(new[] { "foo" }, result); sub.Dispose(); Assert.Equal(0, data.PropertyChangedSubscriptionCount); diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs index 5498926fe4..16b71c87fe 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs @@ -288,11 +288,11 @@ namespace Avalonia.Markup.UnitTests.Data target.OnNext("bar"); Assert.Equal( - new[] + new object[] { - new BindingNotification("5.6"), - new BindingNotification("1.2"), - new BindingNotification("3.4"), + "5.6", + "1.2", + "3.4", new BindingNotification( new InvalidCastException("Error setting 'DoubleValue': Could not convert 'bar' to 'System.Double'"), BindingErrorType.Error) diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs index 4a34791008..7b7128ed2f 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs @@ -14,7 +14,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins public class ExceptionValidationPluginTests { [Fact] - public void Produces_BindingNotifications() + public void Produces_Correct_Results() { var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); var validatorPlugin = new ExceptionValidationPlugin(); @@ -28,12 +28,12 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins validator.SetValue(-2, BindingPriority.LocalValue); validator.SetValue(6, BindingPriority.LocalValue); - Assert.Equal(new[] + Assert.Equal(new object[] { - new BindingNotification(0), - new BindingNotification(5), + 0, + 5, new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), - new BindingNotification(6), + 6, }, result); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs index 788bc25a34..9e6657e0a1 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs @@ -14,7 +14,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins public class IndeiValidationPluginTests { [Fact] - public void Produces_BindingNotifications() + public void Produces_Correct_Results() { var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); var validatorPlugin = new IndeiValidationPlugin(); @@ -29,19 +29,19 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins data.Maximum = 10; data.Maximum = 5; - Assert.Equal(new[] + Assert.Equal(new object[] { - new BindingNotification(0), - new BindingNotification(5), + 0, + 5, // Value is first signalled without an error as validation hasn't been updated. - new BindingNotification(6), + 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), + 6, // And Maximum is changed back to 5. new BindingNotification(new Exception("Must be less than Maximum"), BindingErrorType.DataValidationError, 6), diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs index 8759cb42c5..fd15d97fbf 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -45,10 +45,10 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data Assert.Equal( new[] { - new BindingNotification(5), - new BindingNotification(6), + null, // 5 + null, // 6 new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), - new BindingNotification(7), + null, // 7 }, target.Notifications.AsEnumerable()); } @@ -94,7 +94,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data public IList Notifications { get; } = new List(); - protected override void BindingNotificationReceived(AvaloniaProperty property, BindingNotification notification) + protected override void UpdateDataValidation(AvaloniaProperty property, BindingNotification notification) { Notifications.Add(notification); } From ecfe57246522ae30c25ea80ed449416ab09b984f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 13 Aug 2016 16:00:18 +0200 Subject: [PATCH 21/60] FIx failing test. Introduced in a refactoring a few commits ago where I saw code that looked wrong. It wasn't wrong, it's just the mechanism is a little unintuitive. Added a comment explaining the reasoning. --- src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index 56882ddcbe..87b4772d0a 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs @@ -274,7 +274,13 @@ namespace Avalonia.Markup.Xaml.Data private IObservable GetParentDataContext(IAvaloniaObject target) { - return target.GetObservable(Control.ParentProperty) + // 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) ?? From 66b7b547c4cb5e343ce9ab24ba6891a07a29286b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 13 Aug 2016 19:20:40 +0200 Subject: [PATCH 22/60] Add missing file to .csproj. --- src/Avalonia.Base/Avalonia.Base.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index 963726c158..38eb0d56bc 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -114,6 +114,7 @@ + From d27cab10b5c9e3d536185fa132f9700db516934f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 13 Aug 2016 20:01:47 +0200 Subject: [PATCH 23/60] Added more data validation tests, some failing. --- .../AvaloniaObjectTests_DataValidation.cs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs new file mode 100644 index 0000000000..ef87147c9f --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs @@ -0,0 +1,173 @@ +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.NonValidatedProperty, 6); + target.SetValue(Class1.NonValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); + target.SetValue(Class1.NonValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + target.SetValue(Class1.NonValidatedProperty, new BindingNotification(7)); + target.SetValue(Class1.NonValidatedProperty, 8); + + Assert.Empty(target.Notifications); + } + + [Fact(Skip = "Data validation not yet implemented for non-direct properties")] + public void Setting_Validated_Property_Calls_UpdateDataValidation() + { + var target = new Class1(); + + target.SetValue(Class1.ValidatedProperty, 6); + target.SetValue(Class1.ValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); + target.SetValue(Class1.ValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + target.SetValue(Class1.ValidatedProperty, new BindingNotification(7)); + target.SetValue(Class1.ValidatedProperty, 8); + + Assert.Empty(target.Notifications); + } + + [Fact] + public void Setting_Validated_Direct_Property_Calls_UpdateDataValidation() + { + var target = new Class1(); + + target.SetValue(Class1.ValidatedDirectProperty, 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)); + target.SetValue(Class1.ValidatedDirectProperty, 8); + + Assert.Empty(target.Notifications); + } + + [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(6); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.Error)); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + source.OnNext(new BindingNotification(7)); + source.OnNext(8); + + Assert.Empty(target.Notifications); + } + + [Fact(Skip = "Data validation not yet implemented for non-direct properties")] + public void Binding_Validated_Property_Calls_UpdateDataValidation() + { + var source = new Subject(); + var target = new Class1 + { + [!Class1.ValidatedProperty] = source.AsBinding(), + }; + + source.OnNext(6); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.Error)); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + source.OnNext(new BindingNotification(7)); + source.OnNext(8); + + Assert.Equal( + new[] + { + null, // 6 + new BindingNotification(new Exception(), BindingErrorType.Error), + new BindingNotification(new Exception(), BindingErrorType.DataValidationError), + new BindingNotification(7), // 7 + null, // 8 + }, + target.Notifications.AsEnumerable()); + } + + [Fact] + public void Binding_Validated_Direct_Property_Calls_UpdateDataValidation() + { + var source = new Subject(); + var target = new Class1 + { + [!Class1.ValidatedDirectProperty] = source.AsBinding(), + }; + + source.OnNext(6); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.Error)); + source.OnNext(new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + source.OnNext(new BindingNotification(7)); + source.OnNext(8); + + Assert.Equal( + new[] + { + null, // 6 + new BindingNotification(new Exception(), BindingErrorType.Error), + new BindingNotification(new Exception(), BindingErrorType.DataValidationError), + new BindingNotification(7), // 7 + null, // 8 + }, + target.Notifications.AsEnumerable()); + } + + private class Class1 : AvaloniaObject + { + 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 + { + get { return GetValue(NonValidatedProperty); } + set { SetValue(NonValidatedProperty, value); } + } + + public int Validated + { + get { return GetValue(ValidatedProperty); } + set { SetValue(ValidatedProperty, 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); + } + } + } +} From 5bd0d327fe4a86d4828da411724d9d5c2c9372af Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 15 Aug 2016 23:56:25 +0200 Subject: [PATCH 24/60] Added missing file in .csproj. --- tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj index 959d8e8e2b..ac727dda4c 100644 --- a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj +++ b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj @@ -90,6 +90,7 @@ + From edc538185feec69b3214d597cde9ee925de0e836 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 16 Aug 2016 23:48:26 +0200 Subject: [PATCH 25/60] Handle BindingNotifications in SetValue. As one-time bindings don't set up a binding as such: they just call `SetValue`. --- src/Avalonia.Base/AvaloniaObject.cs | 183 ++++++++++-------- src/Avalonia.Base/Data/BindingNotification.cs | 1 + .../AvaloniaObjectTests_DataValidation.cs | 38 +++- 3 files changed, 140 insertions(+), 82 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index be7a14dcf9..9c6038e79c 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -262,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); } } @@ -361,7 +330,7 @@ namespace Avalonia subscription = source .Select(x => CastOrDefault(x, property.PropertyType)) .Do(_ => { }, () => _directBindings.Remove(subscription)) - .Subscribe(x => DirectBindingSet(property, x)); + .Subscribe(x => SetDirectValue(property, x)); _directBindings.Add(subscription); @@ -642,20 +611,60 @@ namespace Avalonia } /// - /// Sets a property value for a direct property binding. + /// Gets the default value for a property. + /// + /// The property. + /// The default value. + private object GetDefaultValue(AvaloniaProperty property) + { + if (property.Inherits && _inheritanceParent != null) + { + return (_inheritanceParent as AvaloniaObject).GetValueInternal(property); + } + else + { + return ((IStyledPropertyAccessor)property).GetDefaultValue(GetType()); + } + } + + /// + /// Gets a value + /// without check for registered as this can slow getting the value + /// this method is intended for internal usage in AvaloniaObject only + /// it's called only after check the property is registered + /// + /// The property. + /// The value. + private object GetValueInternal(AvaloniaProperty property) + { + object result = AvaloniaProperty.UnsetValue; + PriorityValue value; + + if (_values.TryGetValue(property, out value)) + { + result = value.Value; + } + + if (result == AvaloniaProperty.UnsetValue) + { + result = GetDefaultValue(property); + } + + return result; + } + + /// + /// Sets the value of a direct property. /// /// The property. /// The value. - /// - private void DirectBindingSet(AvaloniaProperty property, object value) + private void SetDirectValue(AvaloniaProperty property, object value) { - var validated = property.GetMetadata(GetType()).EnableDataValidation; + var metadata = property.GetMetadata(GetType()); var notification = value as BindingNotification; if (notification != null) { - value = notification.Value; - if (notification.ErrorType == BindingErrorType.Error) { Logger.Error( @@ -666,73 +675,85 @@ namespace Avalonia property, ExceptionUtilities.GetMessage(notification.Error)); } + + if (notification.HasValue) + { + value = notification.Value; + } } - if (notification?.HasValue != false) + if (notification == null || notification.HasValue) { - SetValue(property, value); + var accessor = (IDirectPropertyAccessor)GetRegistered(property); + var finalValue = value == AvaloniaProperty.UnsetValue ? + ((IDirectPropertyMetadata)metadata).UnsetValue : value; + + LogPropertySet(property, value, BindingPriority.LocalValue); + + accessor.SetValue(this, finalValue); } - if (validated) + if (metadata.EnableDataValidation) { UpdateDataValidation(property, notification); } } /// - /// Converts an unset value to the default value for a direct property. + /// Sets the value of a styled property. /// - /// The value. /// The property. - /// The value. - private object DirectUnsetToDefault(object value, AvaloniaProperty property) + /// The value. + /// The priority of the value. + private void SetStyledValue(AvaloniaProperty property, object value, BindingPriority priority) { - return value == AvaloniaProperty.UnsetValue ? - ((IDirectPropertyMetadata)property.GetMetadata(GetType())).UnsetValue : - value; - } + var notification = value as BindingNotification; - /// - /// Gets the default value for a property. - /// - /// The property. - /// The default value. - private object GetDefaultValue(AvaloniaProperty property) - { - if (property.Inherits && _inheritanceParent != null) + // We currently accept BindingNotifications for non-direct properties but we just + // strip them to their underlying value. + if (notification != null) { - return (_inheritanceParent as AvaloniaObject).GetValueInternal(property); + if (!notification.HasValue) + { + return; + } + else + { + value = notification.Value; + } } - else + + var originalValue = value; + + if (!AvaloniaPropertyRegistry.Instance.IsRegistered(this, property)) { - return ((IStyledPropertyAccessor)property).GetDefaultValue(GetType()); + ThrowNotRegistered(property); } - } - /// - /// Gets a value - /// without check for registered as this can slow getting the value - /// this method is intended for internal usage in AvaloniaObject only - /// it's called only after check the property is registered - /// - /// The property. - /// The value. - private object GetValueInternal(AvaloniaProperty property) - { - object result = AvaloniaProperty.UnsetValue; - PriorityValue value; - - if (_values.TryGetValue(property, out value)) + if (!TypeUtilities.TryCast(property.PropertyType, value, out value)) { - result = value.Value; + throw new ArgumentException(string.Format( + "Invalid value for Property '{0}': '{1}' ({2})", + property.Name, + originalValue, + originalValue?.GetType().FullName ?? "(null)")); } - if (result == AvaloniaProperty.UnsetValue) + PriorityValue v; + + if (!_values.TryGetValue(property, out v)) { - result = GetDefaultValue(property); + if (value == AvaloniaProperty.UnsetValue) + { + return; + } + + v = CreatePriorityValue(property); + _values.Add(property, v); } - return result; + LogPropertySet(property, value, priority); + v.SetValue(value, (int)priority); } /// diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index 9351443e03..1c8a8067f8 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -66,6 +66,7 @@ namespace Avalonia.Data throw new ArgumentException($"'errorType' may not be None"); } + Value = AvaloniaProperty.UnsetValue; Error = error; ErrorType = errorType; } diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs index ef87147c9f..99645ace76 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs @@ -37,6 +37,20 @@ namespace Avalonia.Base.UnitTests 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); + target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); + target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); + target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(7)); + target.SetValue(Class1.NonValidatedDirectProperty, 8); + + Assert.Empty(target.Notifications); + } + [Fact] public void Setting_Validated_Direct_Property_Calls_UpdateDataValidation() { @@ -48,7 +62,16 @@ namespace Avalonia.Base.UnitTests target.SetValue(Class1.ValidatedDirectProperty, new BindingNotification(7)); target.SetValue(Class1.ValidatedDirectProperty, 8); - Assert.Empty(target.Notifications); + Assert.Equal( + new[] + { + null, // 6 + new BindingNotification(new Exception(), BindingErrorType.Error), + new BindingNotification(new Exception(), BindingErrorType.DataValidationError), + new BindingNotification(7), // 7 + null, // 8 + }, + target.Notifications.AsEnumerable()); } [Fact] @@ -135,6 +158,12 @@ namespace Avalonia.Base.UnitTests nameof(Validated), enableDataValidation: true); + 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(Validated), @@ -142,6 +171,7 @@ namespace Avalonia.Base.UnitTests (o, v) => o.ValidatedDirect = v, enableDataValidation: true); + private int _nonValidatedDirect; private int _direct; public int NonValidated @@ -156,6 +186,12 @@ namespace Avalonia.Base.UnitTests set { SetValue(ValidatedProperty, value); } } + public int NonValidatedDirect + { + get { return _direct; } + set { SetAndRaise(NonValidatedDirectProperty, ref _nonValidatedDirect, value); } + } + public int ValidatedDirect { get { return _direct; } From 99a635f31f11fdc821cc26ecdfec2361c002059b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 00:01:12 +0200 Subject: [PATCH 26/60] Remove data validation for non-direct properties. We're going to say that for the moment only direct properties handle data validation. This gets around a few thorny issues with data validation on styled properties. --- src/Avalonia.Base/AvaloniaObject.cs | 4 +- src/Avalonia.Base/AvaloniaObjectExtensions.cs | 4 +- src/Avalonia.Base/AvaloniaProperty.cs | 25 +--- src/Avalonia.Base/DirectPropertyMetadata`1.cs | 16 ++- src/Avalonia.Base/IDirectPropertyMetadata.cs | 5 + src/Avalonia.Base/PropertyMetadata.cs | 18 +-- src/Avalonia.Base/StyledPropertyMetadata`1.cs | 8 +- .../AvaloniaObjectTests_DataValidation.cs | 57 +------- .../Avalonia.Markup.Xaml.UnitTests.csproj | 1 - .../Data/BindingTests_Validation.cs | 126 ------------------ 10 files changed, 34 insertions(+), 230 deletions(-) delete mode 100644 tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 9c6038e79c..d5d97324f7 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -660,7 +660,7 @@ namespace Avalonia /// The value. private void SetDirectValue(AvaloniaProperty property, object value) { - var metadata = property.GetMetadata(GetType()); + var metadata = (IDirectPropertyMetadata)property.GetMetadata(GetType()); var notification = value as BindingNotification; if (notification != null) @@ -686,7 +686,7 @@ namespace Avalonia { var accessor = (IDirectPropertyAccessor)GetRegistered(property); var finalValue = value == AvaloniaProperty.UnsetValue ? - ((IDirectPropertyMetadata)metadata).UnsetValue : value; + metadata.UnsetValue : value; LogPropertySet(property, value, BindingPriority.LocalValue); diff --git a/src/Avalonia.Base/AvaloniaObjectExtensions.cs b/src/Avalonia.Base/AvaloniaObjectExtensions.cs index 77da9d5680..3ca55529e6 100644 --- a/src/Avalonia.Base/AvaloniaObjectExtensions.cs +++ b/src/Avalonia.Base/AvaloniaObjectExtensions.cs @@ -216,11 +216,13 @@ namespace Avalonia Contract.Requires(property != null); Contract.Requires(binding != null); + var metadata = property.GetMetadata(target.GetType()) as IDirectPropertyMetadata; + var result = binding.Initiate( target, property, anchor, - property.GetMetadata(target.GetType()).EnableDataValidation); + metadata?.EnableDataValidation ?? false); if (result != null) { diff --git a/src/Avalonia.Base/AvaloniaProperty.cs b/src/Avalonia.Base/AvaloniaProperty.cs index f12c534aa8..61006b1173 100644 --- a/src/Avalonia.Base/AvaloniaProperty.cs +++ b/src/Avalonia.Base/AvaloniaProperty.cs @@ -251,9 +251,6 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. - /// - /// Whether the property is interested in data validation. - /// /// /// A method that gets called before and after the property starts being notified on an /// object; the bool argument will be true before and false afterwards. This callback is @@ -266,7 +263,6 @@ namespace Avalonia bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, Func validate = null, - bool enableDataValidation = false, Action notifying = null) where TOwner : IAvaloniaObject { @@ -275,8 +271,7 @@ namespace Avalonia var metadata = new StyledPropertyMetadata( defaultValue, validate: Cast(validate), - defaultBindingMode: defaultBindingMode, - enableDataValidation: enableDataValidation); + defaultBindingMode: defaultBindingMode); var result = new StyledProperty( name, @@ -299,17 +294,13 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. - /// - /// Whether the property is interested in data validation. - /// /// A public static AttachedProperty RegisterAttached( string name, TValue defaultValue = default(TValue), bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, - Func validate = null, - bool enableDataValidation = false) + Func validate = null) where THost : IAvaloniaObject { Contract.Requires(name != null); @@ -317,8 +308,7 @@ namespace Avalonia var metadata = new StyledPropertyMetadata( defaultValue, validate: Cast(validate), - defaultBindingMode: defaultBindingMode, - enableDataValidation: enableDataValidation); + defaultBindingMode: defaultBindingMode); var result = new AttachedProperty(name, typeof(TOwner), metadata, inherits); AvaloniaPropertyRegistry.Instance.Register(typeof(THost), result); @@ -336,9 +326,6 @@ namespace Avalonia /// Whether the property inherits its value. /// The default binding mode for the property. /// A validation function. - /// - /// Whether the property is interested in data validation. - /// /// A public static AttachedProperty RegisterAttached( string name, @@ -346,8 +333,7 @@ namespace Avalonia TValue defaultValue = default(TValue), bool inherits = false, BindingMode defaultBindingMode = BindingMode.OneWay, - Func validate = null, - bool enableDataValidation = false) + Func validate = null) where THost : IAvaloniaObject { Contract.Requires(name != null); @@ -355,8 +341,7 @@ namespace Avalonia var metadata = new StyledPropertyMetadata( defaultValue, validate: Cast(validate), - defaultBindingMode: defaultBindingMode, - enableDataValidation: enableDataValidation); + defaultBindingMode: defaultBindingMode); var result = new AttachedProperty(name, ownerType, metadata, inherits); AvaloniaPropertyRegistry.Instance.Register(typeof(THost), result); diff --git a/src/Avalonia.Base/DirectPropertyMetadata`1.cs b/src/Avalonia.Base/DirectPropertyMetadata`1.cs index e446095c06..d22801e35a 100644 --- a/src/Avalonia.Base/DirectPropertyMetadata`1.cs +++ b/src/Avalonia.Base/DirectPropertyMetadata`1.cs @@ -24,16 +24,28 @@ namespace Avalonia TValue unsetValue = default(TValue), BindingMode defaultBindingMode = BindingMode.Default, bool enableDataValidation = false) - : base(defaultBindingMode, enableDataValidation) + : 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/PropertyMetadata.cs b/src/Avalonia.Base/PropertyMetadata.cs index 3fe0e85b0d..395aad53e4 100644 --- a/src/Avalonia.Base/PropertyMetadata.cs +++ b/src/Avalonia.Base/PropertyMetadata.cs @@ -17,15 +17,10 @@ namespace Avalonia /// Initializes a new instance of the class. /// /// The default binding mode. - /// - /// Whether the property is interested in data validation. - /// public PropertyMetadata( - BindingMode defaultBindingMode = BindingMode.Default, - bool enableDataValidation = false) + BindingMode defaultBindingMode = BindingMode.Default) { _defaultBindingMode = defaultBindingMode; - EnableDataValidation = enableDataValidation; } /// @@ -40,17 +35,6 @@ namespace Avalonia } } - /// - /// 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; } - /// /// Merges the metadata with the base metadata. /// diff --git a/src/Avalonia.Base/StyledPropertyMetadata`1.cs b/src/Avalonia.Base/StyledPropertyMetadata`1.cs index 5982ca4506..ed01f1bc70 100644 --- a/src/Avalonia.Base/StyledPropertyMetadata`1.cs +++ b/src/Avalonia.Base/StyledPropertyMetadata`1.cs @@ -18,15 +18,11 @@ namespace Avalonia /// The default value of the property. /// A validation function. /// The default binding mode. - /// - /// Whether the property is interested in data validation. - /// public StyledPropertyMetadata( TValue defaultValue = default(TValue), Func validate = null, - BindingMode defaultBindingMode = BindingMode.Default, - bool enableDataValidation = false) - : base(defaultBindingMode, enableDataValidation) + BindingMode defaultBindingMode = BindingMode.Default) + : base(defaultBindingMode) { DefaultValue = defaultValue; Validate = validate; diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs index 99645ace76..942eccc1c3 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs @@ -23,20 +23,6 @@ namespace Avalonia.Base.UnitTests Assert.Empty(target.Notifications); } - [Fact(Skip = "Data validation not yet implemented for non-direct properties")] - public void Setting_Validated_Property_Calls_UpdateDataValidation() - { - var target = new Class1(); - - target.SetValue(Class1.ValidatedProperty, 6); - target.SetValue(Class1.ValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); - target.SetValue(Class1.ValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); - target.SetValue(Class1.ValidatedProperty, new BindingNotification(7)); - target.SetValue(Class1.ValidatedProperty, 8); - - Assert.Empty(target.Notifications); - } - [Fact] public void Setting_Non_Validated_Direct_Property_Does_Not_Call_UpdateDataValidation() { @@ -92,33 +78,6 @@ namespace Avalonia.Base.UnitTests Assert.Empty(target.Notifications); } - [Fact(Skip = "Data validation not yet implemented for non-direct properties")] - public void Binding_Validated_Property_Calls_UpdateDataValidation() - { - var source = new Subject(); - var target = new Class1 - { - [!Class1.ValidatedProperty] = source.AsBinding(), - }; - - source.OnNext(6); - source.OnNext(new BindingNotification(new Exception(), BindingErrorType.Error)); - source.OnNext(new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); - source.OnNext(new BindingNotification(7)); - source.OnNext(8); - - Assert.Equal( - new[] - { - null, // 6 - new BindingNotification(new Exception(), BindingErrorType.Error), - new BindingNotification(new Exception(), BindingErrorType.DataValidationError), - new BindingNotification(7), // 7 - null, // 8 - }, - target.Notifications.AsEnumerable()); - } - [Fact] public void Binding_Validated_Direct_Property_Calls_UpdateDataValidation() { @@ -150,13 +109,7 @@ namespace Avalonia.Base.UnitTests { public static readonly StyledProperty NonValidatedProperty = AvaloniaProperty.Register( - nameof(Validated), - enableDataValidation: false); - - public static readonly StyledProperty ValidatedProperty = - AvaloniaProperty.Register( - nameof(Validated), - enableDataValidation: true); + nameof(NonValidated)); public static readonly DirectProperty NonValidatedDirectProperty = AvaloniaProperty.RegisterDirect( @@ -166,7 +119,7 @@ namespace Avalonia.Base.UnitTests public static readonly DirectProperty ValidatedDirectProperty = AvaloniaProperty.RegisterDirect( - nameof(Validated), + nameof(ValidatedDirect), o => o.ValidatedDirect, (o, v) => o.ValidatedDirect = v, enableDataValidation: true); @@ -180,12 +133,6 @@ namespace Avalonia.Base.UnitTests set { SetValue(NonValidatedProperty, value); } } - public int Validated - { - get { return GetValue(ValidatedProperty); } - set { SetValue(ValidatedProperty, value); } - } - public int NonValidatedDirect { get { return _direct; } 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 8fb996bad7..cff81b708a 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj @@ -95,7 +95,6 @@ - diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs deleted file mode 100644 index fd15d97fbf..0000000000 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs +++ /dev/null @@ -1,126 +0,0 @@ -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 -{ - public class BindingTests_Validation - { - [Fact] - public void Non_Validated_Property_Does_Not_Receive_BindingNotifications() - { - var source = new ValidationTestModel { MustBePositive = 5 }; - var target = new TestControl - { - DataContext = source, - [!TestControl.NonValidatedProperty] = new Binding(nameof(source.MustBePositive)), - }; - - Assert.Empty(target.Notifications); - } - - [Fact] - public void Validated_Direct_Property_Receives_BindingNotifications() - { - var source = new ValidationTestModel { MustBePositive = 5 }; - var target = new TestControl - { - DataContext = source, - }; - - target.Bind( - TestControl.ValidatedDirectProperty, - new Binding(nameof(source.MustBePositive), BindingMode.TwoWay)); - - target.ValidatedDirect = 6; - target.ValidatedDirect = -1; - target.ValidatedDirect = 7; - - Assert.Equal( - new[] - { - null, // 5 - null, // 6 - new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), - null, // 7 - }, - target.Notifications.AsEnumerable()); - } - - private class TestControl : Control - { - 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 - { - get { return GetValue(NonValidatedProperty); } - set { SetValue(NonValidatedProperty, value); } - } - - public int Validated - { - get { return GetValue(ValidatedProperty); } - set { SetValue(ValidatedProperty, 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); - } - } - - private class ValidationTestModel : NotifyingBase - { - private int _mustBePositive; - - public int MustBePositive - { - get { return _mustBePositive; } - set - { - if (value <= 0) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } - - if (_mustBePositive != value) - { - _mustBePositive = value; - RaisePropertyChanged(); - } - } - } - } - } -} From 760adfc126d7f8f6060456d005382fc5de8edd01 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 01:20:22 +0200 Subject: [PATCH 27/60] Update BindingTest with changes. Data validation still doesn't work in BindingTest though. --- samples/BindingTest/MainWindow.xaml | 2 +- .../BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/BindingTest/MainWindow.xaml b/samples/BindingTest/MainWindow.xaml index 149625925a..e1aafde5b3 100644 --- a/samples/BindingTest/MainWindow.xaml +++ b/samples/BindingTest/MainWindow.xaml @@ -72,7 +72,7 @@ - + diff --git a/samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs b/samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs index 01155f1d9f..be34c65c40 100644 --- a/samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs +++ b/samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs @@ -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."); } } } From 941246e75cd456c7df63abac78d2bc3993278d4d Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 01:23:13 +0200 Subject: [PATCH 28/60] Started to implement data validation on TextBox. Not working still - added a failing test to demonstrate it. --- src/Avalonia.Base/DirectProperty.cs | 11 ++- src/Avalonia.Controls/TextBox.cs | 26 +++--- .../TextBoxTests_ValidationState.cs | 84 ++++++++----------- 3 files changed, 60 insertions(+), 61 deletions(-) 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.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 58adb8106f..cecb2c9cb7 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -35,6 +35,9 @@ namespace Avalonia.Controls o => o.CaretIndex, (o, v) => o.CaretIndex = v); + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + public static readonly DirectProperty SelectionStartProperty = AvaloniaProperty.RegisterDirect( nameof(SelectionStart), @@ -51,7 +54,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 +69,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; } @@ -145,6 +146,12 @@ namespace Avalonia.Controls } } + public bool IsReadOnly + { + get { return GetValue(IsReadOnlyProperty); } + set { SetValue(IsReadOnlyProperty, value); } + } + public int SelectionStart { get @@ -198,12 +205,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); } @@ -461,6 +462,11 @@ namespace Avalonia.Controls } } + protected override void UpdateDataValidation(AvaloniaProperty property, BindingNotification status) + { + ((IPseudoClasses)Classes).Set(":error", status != null && status.ErrorType != BindingErrorType.None); + } + private int CoerceCaretIndex(int value) { var text = Text; diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs index 34de11cc3b..84ce2d59dc 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs @@ -5,8 +5,13 @@ using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel; +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 @@ -14,63 +19,44 @@ namespace Avalonia.Controls.UnitTests public class TextBoxTests_ValidationState { [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(false); - //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(false); - //Assert.True(target.ValidationStatus.IsValid); - //target.Text = "foo"; - //Assert.False(target.ValidationStatus.IsValid); - //target.Text = "1"; - //Assert.True(target.ValidationStatus.IsValid); + Assert.False(target.Classes.Contains(":error")); + target.Text = "20"; + Assert.True(target.Classes.Contains(":error")); + target.Text = "1"; + Assert.False(target.Classes.Contains(":error")); } } - [Fact] - public void Indei_Should_Set_ValidationState() - { - using (UnitTestApplication.Start(TestServices.MockThreadingInterface)) - { - var target = new TextBox(); - var binding = new Binding(nameof(ExceptionTest.LessThan10)); - binding.Source = new IndeiTest(); - ////binding.EnableValidation = true; - target.Bind(TextBox.TextProperty, binding); + private static TestServices Services => TestServices.MockThreadingInterface.With( + standardCursorFactory: Mock.Of()); - Assert.True(false); - //Assert.True(target.ValidationStatus.IsValid); - //target.Text = "20"; - //Assert.False(target.ValidationStatus.IsValid); - //target.Text = "1"; - //Assert.True(target.ValidationStatus.IsValid); - } + 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 From 5a9be7d94ac317cae6e0e11b5fb08f4ca39ae765 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 01:39:30 +0200 Subject: [PATCH 29/60] Make sure new value is returned. When a property value is set, if a new value isn't notified via INPC, then send the new value after the set succeeds. --- .../Plugins/InpcPropertyAccessorPlugin.cs | 19 +++++++------- .../Data/ExpressionObserverTests_Property.cs | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs index a62132be86..138f09b373 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/InpcPropertyAccessorPlugin.cs @@ -54,6 +54,7 @@ namespace Avalonia.Markup.Data.Plugins { private readonly WeakReference _reference; private readonly PropertyInfo _property; + private bool _eventRaised; public Accessor(WeakReference reference, PropertyInfo property) { @@ -79,7 +80,14 @@ namespace Avalonia.Markup.Data.Plugins { if (_property.CanWrite) { + _eventRaised = false; _property.SetValue(_reference.Target, value); + + if (!_eventRaised) + { + SendCurrentValue(); + } + return true; } @@ -90,6 +98,7 @@ namespace Avalonia.Markup.Data.Plugins { if (e.PropertyName == _property.Name || string.IsNullOrEmpty(e.PropertyName)) { + _eventRaised = true; SendCurrentValue(); } } @@ -134,16 +143,6 @@ namespace Avalonia.Markup.Data.Plugins nameof(inpc.PropertyChanged), this); } - else - { - Logger.Information( - LogArea.Binding, - this, - "Bound to property {Property} on {Source} which does not implement INotifyPropertyChanged", - _property.Name, - _reference.Target, - _reference.Target.GetType()); - } } } } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index 887879b164..b65dd091e0 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -437,6 +437,32 @@ namespace Avalonia.Markup.UnitTests.Data } } + [Fact] + public void SetValue_Should_Notify_New_Value_With_Inpc() + { + var data = new Class1(); + var target = new ExpressionObserver(data, "Foo"); + var result = new List(); + + target.Subscribe(x => result.Add(x)); + target.SetValue("bar"); + + Assert.Equal(new[] { null, "bar" }, result); + } + + [Fact] + public void SetValue_Should_Notify_New_Value_Without_Inpc() + { + var data = new Class1(); + var target = new ExpressionObserver(data, "Bar"); + var result = new List(); + + 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() { From 844da05fe897ff3e9b6a0b87ebc12ac97cd6f839 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 01:50:36 +0200 Subject: [PATCH 30/60] Revert "Only send BindingNotifications on error." This reverts commit 57e646583fcd540a9b2795ee5e07700e241ea4f7. For TextBox there can be 2 bindings to Text: one which is the binding to the view model which should have data validation enabled, and another binding to the TextPresenter in the template which should not have data validation enabled, or it will override the view model data validation. For this we need to be able to distinguish between the two and so bindings with data validation enabled need to always send BindingNotifications. Conflicts: src/Avalonia.Base/AvaloniaObject.cs tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs --- src/Avalonia.Base/AvaloniaObject.cs | 2 +- .../Data/Plugins/DataValidatiorBase.cs | 6 +- .../Data/Plugins/IndeiValidationPlugin.cs | 8 +- .../AvaloniaObjectTests_DataValidation.cs | 25 ++-- .../ExpressionObserverTests_DataValidation.cs | 12 +- .../ExpressionObserverTests_Observable.cs | 2 +- .../Data/ExpressionSubjectTests.cs | 8 +- .../Plugins/ExceptionValidationPluginTests.cs | 10 +- .../Plugins/IndeiValidationPluginTests.cs | 12 +- .../Data/BindingTests_Validation.cs | 126 ++++++++++++++++++ 10 files changed, 166 insertions(+), 45 deletions(-) create mode 100644 tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index d5d97324f7..80d1c253b4 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -693,7 +693,7 @@ namespace Avalonia accessor.SetValue(this, finalValue); } - if (metadata.EnableDataValidation) + if (metadata.EnableDataValidation && notification != null) { UpdateDataValidation(property, notification); } diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs b/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs index 9b387456ca..95d269f437 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/DataValidatiorBase.cs @@ -68,11 +68,13 @@ namespace Avalonia.Markup.Data.Plugins /// /// The value. /// - /// Notifies the observer that the value has changed. + /// 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) { - Observer.OnNext(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/IndeiValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs index 561bd66db3..8fb2568f30 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs @@ -40,7 +40,7 @@ namespace Avalonia.Markup.Data.Plugins { if (e.PropertyName == _name || string.IsNullOrEmpty(e.PropertyName)) { - Observer.OnNext(BuildResult(Value)); + Observer.OnNext(CreateBindingNotification(Value)); } } @@ -76,10 +76,10 @@ namespace Avalonia.Markup.Data.Plugins protected override void InnerValueChanged(object value) { - base.InnerValueChanged(BuildResult(value)); + base.InnerValueChanged(CreateBindingNotification(value)); } - private object BuildResult(object value) + private BindingNotification CreateBindingNotification(object value) { var target = (INotifyDataErrorInfo)_reference.Target; @@ -98,7 +98,7 @@ namespace Avalonia.Markup.Data.Plugins } } - return value; + return new BindingNotification(value); } private Exception GenerateException(IList errors) diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs index 942eccc1c3..a74b972b90 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs @@ -14,11 +14,10 @@ namespace Avalonia.Base.UnitTests { var target = new Class1(); - target.SetValue(Class1.NonValidatedProperty, 6); + target.SetValue(Class1.NonValidatedProperty, new BindingNotification(6)); target.SetValue(Class1.NonValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); target.SetValue(Class1.NonValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); target.SetValue(Class1.NonValidatedProperty, new BindingNotification(7)); - target.SetValue(Class1.NonValidatedProperty, 8); Assert.Empty(target.Notifications); } @@ -28,11 +27,10 @@ namespace Avalonia.Base.UnitTests { var target = new Class1(); - target.SetValue(Class1.NonValidatedDirectProperty, 6); + target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(6)); target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(7)); - target.SetValue(Class1.NonValidatedDirectProperty, 8); Assert.Empty(target.Notifications); } @@ -42,20 +40,18 @@ namespace Avalonia.Base.UnitTests { var target = new Class1(); - target.SetValue(Class1.ValidatedDirectProperty, 6); + 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)); - target.SetValue(Class1.ValidatedDirectProperty, 8); Assert.Equal( new[] { - null, // 6 + new BindingNotification(6), new BindingNotification(new Exception(), BindingErrorType.Error), new BindingNotification(new Exception(), BindingErrorType.DataValidationError), - new BindingNotification(7), // 7 - null, // 8 + new BindingNotification(7), }, target.Notifications.AsEnumerable()); } @@ -69,11 +65,10 @@ namespace Avalonia.Base.UnitTests [!Class1.NonValidatedProperty] = source.AsBinding(), }; - source.OnNext(6); + 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)); - source.OnNext(8); Assert.Empty(target.Notifications); } @@ -87,20 +82,18 @@ namespace Avalonia.Base.UnitTests [!Class1.ValidatedDirectProperty] = source.AsBinding(), }; - source.OnNext(6); + 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)); - source.OnNext(8); Assert.Equal( new[] { - null, // 6 + new BindingNotification(6), new BindingNotification(new Exception(), BindingErrorType.Error), new BindingNotification(new Exception(), BindingErrorType.DataValidationError), - new BindingNotification(7), // 7 - null, // 8 + new BindingNotification(7), }, target.Notifications.AsEnumerable()); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index a363d69486..cdcaeda4cc 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -81,16 +81,16 @@ namespace Avalonia.Markup.UnitTests.Data observer.SetValue("foo"); observer.SetValue(5); - Assert.Equal(new object[] + Assert.Equal(new[] { - 0, + new BindingNotification(0), // Value is notified twice as ErrorsChanged is always called by IndeiTest. - 5, - 5, + new BindingNotification(5), + new BindingNotification(5), // Value is first signalled without an error as validation hasn't been updated. - -5, + new BindingNotification(-5), new BindingNotification(new Exception("Must be positive"), BindingErrorType.DataValidationError, -5), // Exception is thrown by trying to set value to "foo". @@ -100,7 +100,7 @@ namespace Avalonia.Markup.UnitTests.Data // Value is set then validation is updated. new BindingNotification(new Exception("Must be positive"), BindingErrorType.DataValidationError, 5), - 5, + new BindingNotification(5), }, result); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs index 3b915f97a8..3263aaace2 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs @@ -85,7 +85,7 @@ namespace Avalonia.Markup.UnitTests.Data data.Next.OnNext(new Class2("foo")); sync.ExecutePostedCallbacks(); - Assert.Equal(new[] { "foo" }, result); + Assert.Equal(new[] { new BindingNotification("foo") }, result); sub.Dispose(); Assert.Equal(0, data.PropertyChangedSubscriptionCount); diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs index 16b71c87fe..5498926fe4 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs @@ -288,11 +288,11 @@ namespace Avalonia.Markup.UnitTests.Data target.OnNext("bar"); Assert.Equal( - new object[] + new[] { - "5.6", - "1.2", - "3.4", + new BindingNotification("5.6"), + new BindingNotification("1.2"), + new BindingNotification("3.4"), new BindingNotification( new InvalidCastException("Error setting 'DoubleValue': Could not convert 'bar' to 'System.Double'"), BindingErrorType.Error) diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs index 7b7128ed2f..4a34791008 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/ExceptionValidationPluginTests.cs @@ -14,7 +14,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins public class ExceptionValidationPluginTests { [Fact] - public void Produces_Correct_Results() + public void Produces_BindingNotifications() { var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); var validatorPlugin = new ExceptionValidationPlugin(); @@ -28,12 +28,12 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins validator.SetValue(-2, BindingPriority.LocalValue); validator.SetValue(6, BindingPriority.LocalValue); - Assert.Equal(new object[] + Assert.Equal(new[] { - 0, - 5, + new BindingNotification(0), + new BindingNotification(5), new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), - 6, + new BindingNotification(6), }, result); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs index 9e6657e0a1..788bc25a34 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/IndeiValidationPluginTests.cs @@ -14,7 +14,7 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins public class IndeiValidationPluginTests { [Fact] - public void Produces_Correct_Results() + public void Produces_BindingNotifications() { var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); var validatorPlugin = new IndeiValidationPlugin(); @@ -29,19 +29,19 @@ namespace Avalonia.Markup.UnitTests.Data.Plugins data.Maximum = 10; data.Maximum = 5; - Assert.Equal(new object[] + Assert.Equal(new[] { - 0, - 5, + new BindingNotification(0), + new BindingNotification(5), // Value is first signalled without an error as validation hasn't been updated. - 6, + 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. - 6, + new BindingNotification(6), // And Maximum is changed back to 5. new BindingNotification(new Exception("Must be less than Maximum"), BindingErrorType.DataValidationError, 6), diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs new file mode 100644 index 0000000000..8759cb42c5 --- /dev/null +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_Validation.cs @@ -0,0 +1,126 @@ +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 +{ + public class BindingTests_Validation + { + [Fact] + public void Non_Validated_Property_Does_Not_Receive_BindingNotifications() + { + var source = new ValidationTestModel { MustBePositive = 5 }; + var target = new TestControl + { + DataContext = source, + [!TestControl.NonValidatedProperty] = new Binding(nameof(source.MustBePositive)), + }; + + Assert.Empty(target.Notifications); + } + + [Fact] + public void Validated_Direct_Property_Receives_BindingNotifications() + { + var source = new ValidationTestModel { MustBePositive = 5 }; + var target = new TestControl + { + DataContext = source, + }; + + target.Bind( + TestControl.ValidatedDirectProperty, + new Binding(nameof(source.MustBePositive), BindingMode.TwoWay)); + + target.ValidatedDirect = 6; + target.ValidatedDirect = -1; + target.ValidatedDirect = 7; + + Assert.Equal( + new[] + { + new BindingNotification(5), + new BindingNotification(6), + new BindingNotification(new ArgumentOutOfRangeException("value"), BindingErrorType.DataValidationError), + new BindingNotification(7), + }, + target.Notifications.AsEnumerable()); + } + + private class TestControl : Control + { + 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 + { + get { return GetValue(NonValidatedProperty); } + set { SetValue(NonValidatedProperty, value); } + } + + public int Validated + { + get { return GetValue(ValidatedProperty); } + set { SetValue(ValidatedProperty, value); } + } + + public int ValidatedDirect + { + get { return _direct; } + set { SetAndRaise(ValidatedDirectProperty, ref _direct, value); } + } + + public IList Notifications { get; } = new List(); + + protected override void BindingNotificationReceived(AvaloniaProperty property, BindingNotification notification) + { + Notifications.Add(notification); + } + } + + private class ValidationTestModel : NotifyingBase + { + private int _mustBePositive; + + public int MustBePositive + { + get { return _mustBePositive; } + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + if (_mustBePositive != value) + { + _mustBePositive = value; + RaisePropertyChanged(); + } + } + } + } + } +} From 0efcdd97c6b2657f95bcb320caf8d6e987c4089d Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 19:05:54 +0200 Subject: [PATCH 31/60] Update doc comment. --- src/Avalonia.Base/AvaloniaObject.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 80d1c253b4..3cd597c9eb 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -472,9 +472,7 @@ namespace Avalonia /// enabled. /// /// The property. - /// - /// The new validation status. A value of null indicates no validation error. - /// + /// The new validation status. protected virtual void UpdateDataValidation( AvaloniaProperty property, BindingNotification status) From d42d1319ff7ce0334ffef2186b2053ad74b7db21 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 19:06:09 +0200 Subject: [PATCH 32/60] `status` will no longer be null. --- src/Avalonia.Controls/TextBox.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index cecb2c9cb7..a43b11ed3a 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -464,7 +464,7 @@ namespace Avalonia.Controls protected override void UpdateDataValidation(AvaloniaProperty property, BindingNotification status) { - ((IPseudoClasses)Classes).Set(":error", status != null && status.ErrorType != BindingErrorType.None); + ((IPseudoClasses)Classes).Set(":error", status.ErrorType != BindingErrorType.None); } private int CoerceCaretIndex(int value) From 4d0d7da9692e753804f93aa6d2170bbbe502efff Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 19:11:13 +0200 Subject: [PATCH 33/60] Always call UpdateDataValidation on notification. This speeds up the common path of no data validation: this way we don't need to get the metadata unless a binding notification is received. This means that if a binding with data validation is assigned to a property that isn't interested then UpdateDataValidation will be called, but the control should just ignore it. --- src/Avalonia.Base/AvaloniaObject.cs | 4 ++-- src/Avalonia.Controls/TextBox.cs | 5 ++++- .../AvaloniaObjectTests_DataValidation.cs | 10 ++-------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 3cd597c9eb..78d87daea8 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -658,7 +658,6 @@ namespace Avalonia /// The value. private void SetDirectValue(AvaloniaProperty property, object value) { - var metadata = (IDirectPropertyMetadata)property.GetMetadata(GetType()); var notification = value as BindingNotification; if (notification != null) @@ -682,6 +681,7 @@ namespace Avalonia if (notification == null || notification.HasValue) { + var metadata = (IDirectPropertyMetadata)property.GetMetadata(GetType()); var accessor = (IDirectPropertyAccessor)GetRegistered(property); var finalValue = value == AvaloniaProperty.UnsetValue ? metadata.UnsetValue : value; @@ -691,7 +691,7 @@ namespace Avalonia accessor.SetValue(this, finalValue); } - if (metadata.EnableDataValidation && notification != null) + if (notification != null) { UpdateDataValidation(property, notification); } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index a43b11ed3a..2d89019f93 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -464,7 +464,10 @@ namespace Avalonia.Controls protected override void UpdateDataValidation(AvaloniaProperty property, BindingNotification status) { - ((IPseudoClasses)Classes).Set(":error", status.ErrorType != BindingErrorType.None); + if (property == TextProperty) + { + ((IPseudoClasses)Classes).Set(":error", status.ErrorType != BindingErrorType.None); + } } private int CoerceCaretIndex(int value) diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs index a74b972b90..c8436c376f 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs @@ -14,10 +14,7 @@ namespace Avalonia.Base.UnitTests { var target = new Class1(); - target.SetValue(Class1.NonValidatedProperty, new BindingNotification(6)); - target.SetValue(Class1.NonValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); - target.SetValue(Class1.NonValidatedProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); - target.SetValue(Class1.NonValidatedProperty, new BindingNotification(7)); + target.SetValue(Class1.NonValidatedDirectProperty, 6); Assert.Empty(target.Notifications); } @@ -27,10 +24,7 @@ namespace Avalonia.Base.UnitTests { var target = new Class1(); - target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(6)); - target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(new Exception(), BindingErrorType.Error)); - target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(new Exception(), BindingErrorType.DataValidationError)); - target.SetValue(Class1.NonValidatedDirectProperty, new BindingNotification(7)); + target.SetValue(Class1.NonValidatedDirectProperty, 6); Assert.Empty(target.Notifications); } From b063ddf8724e4e8e694e10878288a6af031e2f5b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 19:55:32 +0200 Subject: [PATCH 34/60] Only enable data validation for LocalValue bindings. --- .../Avalonia.Markup.Xaml/Data/Binding.cs | 1 + .../Avalonia.Markup.Xaml.UnitTests.csproj | 1 + .../Data/BindingTests_DataValidation.cs | 79 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index 87b4772d0a..6b8b1282e5 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs @@ -91,6 +91,7 @@ namespace Avalonia.Markup.Xaml.Data var pathInfo = ParsePath(Path); ValidateState(pathInfo); + enableDataValidation = enableDataValidation && Priority == BindingPriority.LocalValue; ExpressionObserver observer; 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 cff81b708a..3ab344c24e 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj @@ -93,6 +93,7 @@ + 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..4f175ccf5b --- /dev/null +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs @@ -0,0 +1,79 @@ +// 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; +using System.Reactive.Linq; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Markup.Data; +using Avalonia.Markup.Xaml.Data; +using Moq; +using ReactiveUI; +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 = (ExpressionSubject)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 = (ExpressionSubject)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 = (ExpressionSubject)instanced.Subject; + object result = null; + + subject.Subscribe(x => result = x); + + Assert.IsType(result); + } + + private class Class1 + { + public string Foo { get; set; } = "foo"; + } + } +} From 19b034651e5b9431daf5de7164e5befb347d4145 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 19:55:46 +0200 Subject: [PATCH 35/60] Use `:error` pseudoclass in TextBox. --- src/Avalonia.Themes.Default/TextBox.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Themes.Default/TextBox.xaml b/src/Avalonia.Themes.Default/TextBox.xaml index c68fab958e..a2e54859c1 100644 --- a/src/Avalonia.Themes.Default/TextBox.xaml +++ b/src/Avalonia.Themes.Default/TextBox.xaml @@ -57,7 +57,7 @@ - \ No newline at end of file From ab515ee5134f679785a8465d945b46537f9ef003 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 22:25:01 +0200 Subject: [PATCH 36/60] Added INDEI validation to BindingTest. --- samples/BindingTest/BindingTest.csproj | 3 +- samples/BindingTest/MainWindow.xaml | 7 +- ...iewModel.cs => ExceptionErrorViewModel.cs} | 2 +- .../ViewModels/IndeiErrorViewModel.cs | 73 +++++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 4 +- 5 files changed, 84 insertions(+), 5 deletions(-) rename samples/BindingTest/ViewModels/{ExceptionPropertyErrorViewModel.cs => ExceptionErrorViewModel.cs} (91%) create mode 100644 samples/BindingTest/ViewModels/IndeiErrorViewModel.cs diff --git a/samples/BindingTest/BindingTest.csproj b/samples/BindingTest/BindingTest.csproj index 441802e396..ac783d1471 100644 --- a/samples/BindingTest/BindingTest.csproj +++ b/samples/BindingTest/BindingTest.csproj @@ -80,7 +80,8 @@ TestItemView.xaml - + + diff --git a/samples/BindingTest/MainWindow.xaml b/samples/BindingTest/MainWindow.xaml index e1aafde5b3..614e3b80c7 100644 --- a/samples/BindingTest/MainWindow.xaml +++ b/samples/BindingTest/MainWindow.xaml @@ -70,10 +70,15 @@ - + + + + + + diff --git a/samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs b/samples/BindingTest/ViewModels/ExceptionErrorViewModel.cs similarity index 91% rename from samples/BindingTest/ViewModels/ExceptionPropertyErrorViewModel.cs rename to samples/BindingTest/ViewModels/ExceptionErrorViewModel.cs index be34c65c40..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; 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..e38d19612e 100644 --- a/samples/BindingTest/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingTest/ViewModels/MainWindowViewModel.cs @@ -69,7 +69,7 @@ namespace BindingTest.ViewModels public ReactiveCommand StringValueCommand { get; } - public ExceptionPropertyErrorViewModel ExceptionPropertyValidation { get; } - = new ExceptionPropertyErrorViewModel(); + public ExceptionErrorViewModel ExceptionDataValidation { get; } = new ExceptionErrorViewModel(); + public IndeiErrorViewModel IndeiDataValidation { get; } = new IndeiErrorViewModel(); } } From 50b0b687cd0de21b25e73cebc6a056716e817067 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 23:11:16 +0200 Subject: [PATCH 37/60] Moved some leak tests to LeakTests. So they can be debugged in dotMemory. --- .../Data/PropertyAccessorNode.cs | 2 +- .../Avalonia.LeakTests.csproj | 1 + .../ExpressionObserverTests.cs | 74 +++++++++++++++++++ .../Data/ExpressionObserverTests_Indexer.cs | 42 +---------- 4 files changed, 79 insertions(+), 40 deletions(-) create mode 100644 tests/Avalonia.LeakTests/ExpressionObserverTests.cs diff --git a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs index 35fc444ec1..a0f0ef000c 100644 --- a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs +++ b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs @@ -12,7 +12,7 @@ namespace Avalonia.Markup.Data { internal class PropertyAccessorNode : ExpressionNode { - private bool _enableValidation; + private readonly bool _enableValidation; private IPropertyAccessor _accessor; public PropertyAccessorNode(string propertyName, bool enableValidation) diff --git a/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj b/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj index 860a4074e3..06e6c8963d 100644 --- a/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj +++ b/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj @@ -84,6 +84,7 @@ + diff --git a/tests/Avalonia.LeakTests/ExpressionObserverTests.cs b/tests/Avalonia.LeakTests/ExpressionObserverTests.cs new file mode 100644 index 0000000000..11eb232b82 --- /dev/null +++ b/tests/Avalonia.LeakTests/ExpressionObserverTests.cs @@ -0,0 +1,74 @@ +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_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/Data/ExpressionObserverTests_Indexer.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs index 75cf606042..f6c4540611 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Indexer.cs @@ -192,55 +192,19 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal(0, data.Foo.PropertyChangedSubscriptionCount); } - [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 { Foo = 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); - } - 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); } } From 91b855b056719f2234402c9d8ff0e91d3bdb177d Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 17 Aug 2016 23:30:07 +0200 Subject: [PATCH 38/60] Convert to WeakReference in ExpressionObserver. Doing a `Publish().Refcount()` caches the latest value so it can be sent to subsequent subscribers, and this causes a leak. Convert the value to/from `WeakReference` for that part. --- .../Avalonia.Markup/Data/ExpressionObserver.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 358464a847..512741702b 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -177,9 +177,11 @@ namespace Avalonia.Markup.Data } _result = Observable.Using(StartRoot, _ => source) + .Select(ToWeakReference) .Publish(UninitializedValue) .RefCount() - .Where(x => x != UninitializedValue); + .Where(x => x != UninitializedValue) + .Select(FromWeakReference); } return _result.Subscribe(observer); @@ -197,6 +199,16 @@ namespace Avalonia.Markup.Data } } + private static object ToWeakReference(object o) + { + return o is BindingNotification ? o : new WeakReference(o); + } + + private static object FromWeakReference(object o) + { + return o is WeakReference ? ((WeakReference)o).Target : o; + } + private IDisposable StartRoot() { var observable = _root as IObservable; From a560c3b6d32bdebcf5e941fb768ed6ad5e9fbd11 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 00:19:15 +0200 Subject: [PATCH 39/60] Use WeakReference in BindingNotification. So that it doesn't keep objects alive when cached by `.Publish().RefCount()` in `ExpressionObserver`. Added a leak test to test that. --- src/Avalonia.Base/AvaloniaObject.cs | 2 +- src/Avalonia.Base/Data/BindingNotification.cs | 67 ++++++++++++++----- src/Avalonia.Base/PriorityValue.cs | 2 +- .../Avalonia.Markup/Data/ExpressionSubject.cs | 19 ++++-- .../ExpressionObserverTests.cs | 18 +++++ .../Data/BindingTests.cs | 5 +- 6 files changed, 86 insertions(+), 27 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 78d87daea8..0f2afc6474 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -577,7 +577,7 @@ namespace Avalonia { if (notification.HasValue) { - notification.Value = TypeUtilities.CastOrDefault(notification.Value, type); + notification.SetValue(TypeUtilities.CastOrDefault(notification.Value, type)); } return notification; diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index 1c8a8067f8..d61e9ad3b2 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -44,14 +44,19 @@ namespace Avalonia.Data 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 = value; - HasValue = true; + _value = new WeakReference(value ?? NullValue); } /// @@ -66,7 +71,6 @@ namespace Avalonia.Data throw new ArgumentException($"'errorType' may not be None"); } - Value = AvaloniaProperty.UnsetValue; Error = error; ErrorType = errorType; } @@ -80,20 +84,42 @@ namespace Avalonia.Data public BindingNotification(Exception error, BindingErrorType errorType, object fallbackValue) : this(error, errorType) { - Value = fallbackValue; - HasValue = true; + _value = new WeakReference(fallbackValue ?? NullValue); } /// /// Gets the value that should be passed to the target when /// is true. /// - public object Value { get; set; } + /// + /// 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 { get; set; } + public bool HasValue => _value != null; /// /// Gets the error that occurred on the source, if any. @@ -179,14 +205,7 @@ namespace Avalonia.Data Contract.Requires(e != null); Contract.Requires(type != BindingErrorType.None); - if (Error != null) - { - Error = new AggregateException(Error, e); - } - else - { - Error = e; - } + Error = Error != null ? new AggregateException(Error, e) : e; if (type == BindingErrorType.Error || ErrorType == BindingErrorType.Error) { @@ -194,10 +213,26 @@ namespace Avalonia.Data } } + /// + /// Removes the and makes return null. + /// + public void ClearValue() + { + _value = null; + } + + /// + /// Sets the . + /// + public void SetValue(object value) + { + _value = new WeakReference(value ?? NullValue); + } + private static bool ExceptionEquals(Exception a, Exception b) { return a?.GetType() == b?.GetType() && - a.Message == b.Message; + a?.Message == b?.Message; } } } diff --git a/src/Avalonia.Base/PriorityValue.cs b/src/Avalonia.Base/PriorityValue.cs index cfe6e8818d..11bd6c61cd 100644 --- a/src/Avalonia.Base/PriorityValue.cs +++ b/src/Avalonia.Base/PriorityValue.cs @@ -259,7 +259,7 @@ namespace Avalonia if (notification?.HasValue == true) { - notification.Value = castValue; + notification.SetValue(castValue); } if (notification == null || notification.HasValue) diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs index 5a7bb80e87..85609c247c 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs @@ -255,7 +255,7 @@ namespace Avalonia.Markup.Data } } - private BindingNotification Merge(object a, BindingNotification b) + private static BindingNotification Merge(object a, BindingNotification b) { var an = a as BindingNotification; @@ -270,7 +270,7 @@ namespace Avalonia.Markup.Data } } - private BindingNotification Merge(BindingNotification a, object b) + private static BindingNotification Merge(BindingNotification a, object b) { var bn = b as BindingNotification; @@ -280,17 +280,22 @@ namespace Avalonia.Markup.Data } else { - a.Value = b; - a.HasValue = true; + a.SetValue(b); } return a; } - private BindingNotification Merge(BindingNotification a, BindingNotification b) + private static BindingNotification Merge(BindingNotification a, BindingNotification b) { - a.Value = b.Value; - a.HasValue = b.HasValue; + if (b.HasValue) + { + a.SetValue(b.Value); + } + else + { + a.ClearValue(); + } if (b.Error != null) { diff --git a/tests/Avalonia.LeakTests/ExpressionObserverTests.cs b/tests/Avalonia.LeakTests/ExpressionObserverTests.cs index 11eb232b82..3dbc62424f 100644 --- a/tests/Avalonia.LeakTests/ExpressionObserverTests.cs +++ b/tests/Avalonia.LeakTests/ExpressionObserverTests.cs @@ -35,6 +35,24 @@ namespace Avalonia.LeakTests 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() { diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs index 4ea281a79c..b07a031e15 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs @@ -166,15 +166,16 @@ 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 values = new List(); - child.GetObservable(Border.DataContextProperty).Subscribe(x => values.Add(x)); + 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 From a222fa9b0c2c14380db40d98dd553f2d744517f2 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 01:24:08 +0200 Subject: [PATCH 40/60] Don't modify text due to binding when typing. If a TextBox is e.g. bound to an int and the user types "02" then the TextBox text should be "02" - not the value that comes back from the binding which will be "2". --- src/Avalonia.Controls/TextBox.cs | 30 +++++++-- .../TextBoxTests.cs | 65 +++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 2d89019f93..67cf04a4a1 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -90,6 +90,7 @@ namespace Avalonia.Controls private bool _canScrollHorizontally; private TextPresenter _presenter; private UndoRedoHelper _undoRedoHelper; + private bool _ignoreTextChanges; static TextBox() { @@ -184,7 +185,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 @@ -247,7 +254,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(); @@ -355,7 +362,7 @@ namespace Avalonia.Controls case Key.Back: if (!DeleteSelection() && CaretIndex > 0) { - Text = text.Substring(0, caretIndex - 1) + text.Substring(caretIndex); + SetTextInternal(text.Substring(0, caretIndex - 1) + text.Substring(caretIndex)); --CaretIndex; } @@ -364,7 +371,7 @@ namespace Avalonia.Controls case Key.Delete: if (!DeleteSelection() && caretIndex < text.Length) { - Text = text.Substring(0, caretIndex) + text.Substring(caretIndex + 1); + SetTextInternal(text.Substring(0, caretIndex) + text.Substring(caretIndex + 1)); } break; @@ -598,7 +605,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; } @@ -645,6 +652,19 @@ namespace Avalonia.Controls return i; } + private void SetTextInternal(string value) + { + try + { + _ignoreTextChanges = true; + SetAndRaise(TextProperty, ref _text, value); + } + finally + { + _ignoreTextChanges = false; + } + } + UndoRedoState UndoRedoHelper.IUndoRedoHost.UndoRedoState { get { return new UndoRedoState(Text, CaretIndex); } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index d19bfebb00..12121fcf6c 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -1,7 +1,14 @@ // 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; using Xunit; namespace Avalonia.Controls.UnitTests @@ -15,5 +22,63 @@ namespace Avalonia.Controls.UnitTests BindingMode.TwoWay, TextBox.TextProperty.GetMetadata(typeof(TextBox)).DefaultBindingMode); } + + [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), + }, + }); + } + + private class Class1 : NotifyingBase + { + private int _foo; + + public int Foo + { + get { return _foo; } + set { _foo = value; RaisePropertyChanged(); } + } + } } } From 41ba3b778200bf31e591d87986c10750cc205c96 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 13:59:17 +0200 Subject: [PATCH 41/60] Started adding TextBox error adornments. --- src/Avalonia.Controls/TextBox.cs | 25 +++++++++++++- .../Accents/BaseLight.xaml | 1 + src/Avalonia.Themes.Default/TextBox.xaml | 34 +++++++++++++++---- .../Avalonia.Controls.UnitTests.csproj | 2 +- ...tate.cs => TextBoxTests_DataValidation.cs} | 26 +++++++++++++- 5 files changed, 78 insertions(+), 10 deletions(-) rename tests/Avalonia.Controls.UnitTests/{TextBoxTests_ValidationState.cs => TextBoxTests_DataValidation.cs} (80%) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 67cf04a4a1..409e416680 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -35,6 +35,11 @@ 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)); @@ -91,6 +96,7 @@ namespace Avalonia.Controls private TextPresenter _presenter; private UndoRedoHelper _undoRedoHelper; private bool _ignoreTextChanges; + private IEnumerable _dataValidationErrors; static TextBox() { @@ -147,6 +153,12 @@ namespace Avalonia.Controls } } + public IEnumerable DataValidationErrors + { + get { return _dataValidationErrors; } + private set { SetAndRaise(DataValidationErrorsProperty, ref _dataValidationErrors, value); } + } + public bool IsReadOnly { get { return GetValue(IsReadOnlyProperty); } @@ -473,7 +485,18 @@ namespace Avalonia.Controls { if (property == TextProperty) { - ((IPseudoClasses)Classes).Set(":error", status.ErrorType != BindingErrorType.None); + var classes = (IPseudoClasses)Classes; + + if (status.ErrorType == BindingErrorType.None) + { + classes.Remove(":error"); + DataValidationErrors = null; + } + else + { + classes.Add(":error"); + DataValidationErrors = new[] { status.Error }; + } } } diff --git a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml index 2d1e833199..0dae3e1a21 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml @@ -18,6 +18,7 @@ #99119EDA #66119EDA #33119EDA + Red 2 0.5 diff --git a/src/Avalonia.Themes.Default/TextBox.xaml b/src/Avalonia.Themes.Default/TextBox.xaml index a2e54859c1..89297d2446 100644 --- a/src/Avalonia.Themes.Default/TextBox.xaml +++ b/src/Avalonia.Themes.Default/TextBox.xaml @@ -36,13 +36,24 @@ Opacity="0.5" Text="{TemplateBinding Watermark}" IsVisible="{TemplateBinding Path=Text, Converter={Static StringConverters.NullOrEmpty}}"/> - + + + + + + + + + + + + @@ -58,6 +69,15 @@ + + + \ No newline at end of file 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_ValidationState.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs similarity index 80% rename from tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs rename to tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs index 84ce2d59dc..ff3634b9fe 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_ValidationState.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs @@ -5,6 +5,7 @@ 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; @@ -16,7 +17,7 @@ using Xunit; namespace Avalonia.Controls.UnitTests { - public class TextBoxTests_ValidationState + public class TextBoxTests_DataValidation { [Fact] public void Setter_Exceptions_Should_Set_Error_Pseudoclass() @@ -40,6 +41,29 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Setter_Exceptions_Should_Set_DataValidationErrors() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + DataContext = new ExceptionTest(), + [!TextBox.TextProperty] = new Binding(nameof(ExceptionTest.LessThan10), BindingMode.TwoWay), + Template = CreateTemplate(), + }; + + target.ApplyTemplate(); + + Assert.Null(target.DataValidationErrors); + target.Text = "20"; + Assert.Equal(1, target.DataValidationErrors.Count()); + Assert.IsType(target.DataValidationErrors.Single()); + target.Text = "1"; + Assert.Null(target.DataValidationErrors); + } + } + private static TestServices Services => TestServices.MockThreadingInterface.With( standardCursorFactory: Mock.Of()); From 9094de563e7db009b703239d6fe1b4e855455477 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 17:00:51 +0200 Subject: [PATCH 42/60] Fix the TextBox error ToolTip. Create a new popup window for each ToolTip as sharing one was causing problems when ToolTip was included in a control template. --- src/Avalonia.Controls/ToolTip.cs | 23 +++++++++++-------- .../Accents/BaseLight.xaml | 1 + src/Avalonia.Themes.Default/DefaultTheme.xaml | 4 +++- src/Avalonia.Themes.Default/TextBox.xaml | 3 ++- 4 files changed, 19 insertions(+), 12 deletions(-) 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.Themes.Default/Accents/BaseLight.xaml b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml index 0dae3e1a21..38786f54d3 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml @@ -19,6 +19,7 @@ #66119EDA #33119EDA Red + #10ff0000 2 0.5 diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index 0fab9ceec7..026834e808 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 89297d2446..a41f40cc5f 100644 --- a/src/Avalonia.Themes.Default/TextBox.xaml +++ b/src/Avalonia.Themes.Default/TextBox.xaml @@ -78,6 +78,7 @@ \ No newline at end of file From 7a2adc63e98e1ca8c9ce6d4ffbe323734003c395 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 17:05:13 +0200 Subject: [PATCH 43/60] Fix TextBox/error icon scrolling. --- src/Avalonia.Themes.Default/TextBox.xaml | 48 ++++++++++++------------ 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/Avalonia.Themes.Default/TextBox.xaml b/src/Avalonia.Themes.Default/TextBox.xaml index a41f40cc5f..262c271e85 100644 --- a/src/Avalonia.Themes.Default/TextBox.xaml +++ b/src/Avalonia.Themes.Default/TextBox.xaml @@ -27,37 +27,39 @@ - - + + + + + + + + + + + + + + - - - - - - - - - - - - + - + + From 8b9f25ff2462506414e0ee7dc69ca5b5b218f641 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 19:26:02 +0200 Subject: [PATCH 44/60] Fix OneTime bindings. OneTime bindings were failing in BindingTest because the initial binding error was being counted as the single value to transfer. Don't do this with OneTime bindings - only transfer valid values. --- src/Avalonia.Base/Data/BindingNotification.cs | 15 ++++++ src/Avalonia.Base/Data/BindingOperations.cs | 5 +- .../AvaloniaObjectTests_Binding.cs | 49 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index d61e9ad3b2..c9e6e3b804 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -166,6 +166,21 @@ namespace Avalonia.Data 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; + } + /// /// Compares an object to an instance of for equality. /// 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/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs index 5c3459e3db..1757550d4a 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() { @@ -352,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); + } + } } } From 6ca54f7de59992e82727c479c05d01928275c623 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 22:01:42 +0200 Subject: [PATCH 45/60] Move Avalonia.Markup.UnitTests to .net 4.5. --- .../Avalonia.Markup.UnitTests.csproj | 16 +++++++++++----- .../Data/ExpressionObserverTests_Task.cs | 9 ++++++++- tests/Avalonia.Markup.UnitTests/app.config | 8 ++++---- tests/Avalonia.Markup.UnitTests/packages.config | 12 +++++++----- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj index 6f92e88337..4f3f76c15e 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 @@ -45,7 +46,7 @@ - ..\..\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 +54,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 +71,7 @@ + ..\..\packages\xunit.abstractions.2.0.0\lib\net35\xunit.abstractions.dll True diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs index 48b93107b1..3dcd8a4fbc 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs @@ -95,7 +95,7 @@ namespace Avalonia.Markup.UnitTests.Data { using (var sync = UnitTestSynchronizationContext.Begin()) { - var data = new { Foo = Task.FromException(new NotSupportedException()) }; + var data = new { Foo = TaskFromException(new NotSupportedException()) }; var target = new ExpressionObserver(data, "Foo"); var result = new List(); @@ -132,6 +132,13 @@ namespace Avalonia.Markup.UnitTests.Data } } + 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/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..ff66c030c1 100644 --- a/tests/Avalonia.Markup.UnitTests/packages.config +++ b/tests/Avalonia.Markup.UnitTests/packages.config @@ -1,11 +1,13 @@  - + - - - - + + + + + + From 8addd927e2b376352785d9f9c23af17e0fd6d782 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 23:01:08 +0200 Subject: [PATCH 46/60] Add string representation of BindingNotification. For easier debugging. --- src/Avalonia.Base/Data/BindingNotification.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index c9e6e3b804..e92ea8422a 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -244,6 +244,20 @@ namespace Avalonia.Data _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() && From 57a611533cd4d7e7e426853bb1d6474c7cddeecc Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 23:08:05 +0200 Subject: [PATCH 47/60] Added Data Annotations validation. --- samples/BindingTest/BindingTest.csproj | 2 + samples/BindingTest/MainWindow.xaml | 4 + .../DataAnnotationsErrorViewModel.cs | 14 +++ .../ViewModels/MainWindowViewModel.cs | 1 + .../Avalonia.Markup/Avalonia.Markup.csproj | 1 + .../Data/ExpressionObserver.cs | 1 + .../DataAnnotationsValidationPlugin.cs | 81 +++++++++++++ .../Data/Plugins/ExceptionValidationPlugin.cs | 2 +- .../Data/Plugins/IDataValidationPlugin.cs | 3 +- .../Data/Plugins/IndeiValidationPlugin.cs | 2 +- .../Data/PropertyAccessorNode.cs | 2 +- src/Markup/Avalonia.Markup/packages.config | 1 + .../Avalonia.Markup.UnitTests.csproj | 5 + .../DataAnnotationsValidationPluginTests.cs | 111 ++++++++++++++++++ .../Avalonia.Markup.UnitTests/packages.config | 1 + 15 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs create mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/DataAnnotationsValidationPlugin.cs create mode 100644 tests/Avalonia.Markup.UnitTests/Data/Plugins/DataAnnotationsValidationPluginTests.cs diff --git a/samples/BindingTest/BindingTest.csproj b/samples/BindingTest/BindingTest.csproj index ac783d1471..a719dd2517 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,6 +81,7 @@ TestItemView.xaml + diff --git a/samples/BindingTest/MainWindow.xaml b/samples/BindingTest/MainWindow.xaml index 614e3b80c7..f0bb169f3c 100644 --- a/samples/BindingTest/MainWindow.xaml +++ b/samples/BindingTest/MainWindow.xaml @@ -79,6 +79,10 @@ + + + + diff --git a/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs b/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs new file mode 100644 index 0000000000..b622ee9e18 --- /dev/null +++ b/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs @@ -0,0 +1,14 @@ +// 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; } + } +} diff --git a/samples/BindingTest/ViewModels/MainWindowViewModel.cs b/samples/BindingTest/ViewModels/MainWindowViewModel.cs index e38d19612e..94f7ff595a 100644 --- a/samples/BindingTest/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingTest/ViewModels/MainWindowViewModel.cs @@ -69,6 +69,7 @@ namespace BindingTest.ViewModels public ReactiveCommand StringValueCommand { get; } + public DataAnnotationsErrorViewModel DataAnnotationsValidation { get; } = new DataAnnotationsErrorViewModel(); public ExceptionErrorViewModel ExceptionDataValidation { get; } = new ExceptionErrorViewModel(); public IndeiErrorViewModel IndeiDataValidation { get; } = new IndeiErrorViewModel(); } diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 780c05d18d..411effd212 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -47,6 +47,7 @@ + diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 512741702b..486ee59469 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -35,6 +35,7 @@ namespace Avalonia.Markup.Data public static readonly IList DataValidators = new List { + new DataAnnotationsValidationPlugin(), new IndeiValidationPlugin(), new ExceptionValidationPlugin(), }; 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/ExceptionValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs index 8c3c10aeb9..e0b6bcfd7c 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs @@ -13,7 +13,7 @@ namespace Avalonia.Markup.Data.Plugins public class ExceptionValidationPlugin : IDataValidationPlugin { /// - public bool Match(WeakReference reference) => true; + public bool Match(WeakReference reference, string memberName) => true; /// public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor inner) diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs index 7449c65245..0952e2edab 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs @@ -15,8 +15,9 @@ namespace Avalonia.Markup.Data.Plugins /// 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); + bool Match(WeakReference reference, string memberName); /// /// Starts monitoring the data validation state of a property on an object. diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs index 8fb2568f30..82bc87c207 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs @@ -16,7 +16,7 @@ namespace Avalonia.Markup.Data.Plugins public class IndeiValidationPlugin : IDataValidationPlugin { /// - public bool Match(WeakReference reference) => reference.Target is INotifyDataErrorInfo; + public bool Match(WeakReference reference, string memberName) => reference.Target is INotifyDataErrorInfo; /// public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor accessor) diff --git a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs index a0f0ef000c..ebeebcd07d 100644 --- a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs +++ b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs @@ -44,7 +44,7 @@ namespace Avalonia.Markup.Data { foreach (var validator in ExpressionObserver.DataValidators) { - if (validator.Match(reference)) + if (validator.Match(reference, PropertyName)) { accessor = validator.Start(reference, PropertyName, accessor); } 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.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj index 4f3f76c15e..55160f1698 100644 --- a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj +++ b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj @@ -44,6 +44,10 @@ 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\net45\System.Reactive.Core.dll @@ -92,6 +96,7 @@ + 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/packages.config b/tests/Avalonia.Markup.UnitTests/packages.config index ff66c030c1..d264c076fd 100644 --- a/tests/Avalonia.Markup.UnitTests/packages.config +++ b/tests/Avalonia.Markup.UnitTests/packages.config @@ -2,6 +2,7 @@ + From 6db7687c7823831eec0992ba7ebd9d0be8cf0b39 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 23:15:14 +0200 Subject: [PATCH 48/60] Unpack AggregateException in TextBox. --- src/Avalonia.Controls/TextBox.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 409e416680..5552d3d3c5 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -495,11 +495,17 @@ namespace Avalonia.Controls else { classes.Add(":error"); - DataValidationErrors = new[] { status.Error }; + DataValidationErrors = UnpackException(status.Error); } } } + private static IEnumerable UnpackException(Exception exception) + { + var aggregate = exception as AggregateException; + return aggregate == null ? Enumerable.Repeat(exception, 1) : aggregate.InnerExceptions; + } + private int CoerceCaretIndex(int value) { var text = Text; From b53fd363e598cc8215143d6444532bc001ab22f7 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 00:06:19 +0200 Subject: [PATCH 49/60] Fix logic in setting templated parent in Popup. The brackets were in the wrong place! --- src/Avalonia.Controls/Primitives/PopupRoot.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()) { From 1db12af4dc2321a05cc346545bc128cac8f2ce35 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 00:16:55 +0200 Subject: [PATCH 50/60] Don't clear TemplatedParent on removal from logical tree. This meant that if a control with a template was removed, when it gets re-added all of the template children's bindings are broken. The logic to clear the TemplatedParent when applying a new template is in TemplatedControl.ApplyTemplate - this is where it should be done. --- src/Avalonia.Controls/Control.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 82212a17fe..5cd2ddfc35 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -721,7 +721,6 @@ namespace Avalonia.Controls _isAttachedToLogicalTree = false; _styleDetach.OnNext(this); - this.TemplatedParent = null; OnDetachedFromLogicalTree(e); foreach (var child in LogicalChildren.OfType()) From 4c0b82a68d0c56a5b3ffdff53e20dd255fecf6a4 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 00:37:39 +0200 Subject: [PATCH 51/60] Always use an IList (array) for DataValidationErrors. It's dangerous to pass a non-collection IEnumerable to an ItemsControl and will be made illegal at some point. --- src/Avalonia.Controls/TextBox.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 5552d3d3c5..1003fef85f 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -503,7 +503,9 @@ namespace Avalonia.Controls private static IEnumerable UnpackException(Exception exception) { var aggregate = exception as AggregateException; - return aggregate == null ? Enumerable.Repeat(exception, 1) : aggregate.InnerExceptions; + return aggregate == null ? + (IEnumerable)new[] { exception } : + aggregate.InnerExceptions; } private int CoerceCaretIndex(int value) From 922b0c5cd708886c308891b7ca90489d25de4ead Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 00:40:41 +0200 Subject: [PATCH 52/60] Don't clear ItemContainerGenerator on template change. Can't remember why this was there but it was wrong anway - it wasn't actually removing the items from the presenter panel. Doesn't seem to break any unit tests so hope for the best... --- src/Avalonia.Controls/ItemsControl.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 8e8603ca24..7d9c9fcc40 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -287,17 +287,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. /// From f59c16bc002cc9525a0e54303a0286f9edbe560f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 01:58:35 +0200 Subject: [PATCH 53/60] Improve broken binding diagnostic messages. --- src/Avalonia.Base/Avalonia.Base.csproj | 1 + .../Data/BindingBrokenException.cs | 15 +++++ src/Avalonia.Base/Data/BindingNotification.cs | 15 +++++ .../Avalonia.Markup/Avalonia.Markup.csproj | 1 + .../Data/EmptyExpressionNode.cs | 2 + .../Avalonia.Markup/Data/ExpressionNode.cs | 9 ++- .../Data/ExpressionObserver.cs | 23 ++++++- .../Avalonia.Markup/Data/IndexerNode.cs | 2 + .../Avalonia.Markup/Data/LogicalNotNode.cs | 2 + .../Data/MarkupBindingBrokenException.cs | 65 +++++++++++++++++++ .../Data/PropertyAccessorNode.cs | 2 +- .../ExpressionObserverTests_DataValidation.cs | 2 +- .../Data/ExpressionObserverTests_Property.cs | 16 ++--- .../Xaml/ControlBindingTests.cs | 2 +- 14 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 src/Avalonia.Base/Data/BindingBrokenException.cs create mode 100644 src/Markup/Avalonia.Markup/Data/MarkupBindingBrokenException.cs diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index 38eb0d56bc..97dfd1230d 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -43,6 +43,7 @@ Properties\SharedAssemblyInfo.cs + diff --git a/src/Avalonia.Base/Data/BindingBrokenException.cs b/src/Avalonia.Base/Data/BindingBrokenException.cs new file mode 100644 index 0000000000..057629edb8 --- /dev/null +++ b/src/Avalonia.Base/Data/BindingBrokenException.cs @@ -0,0 +1,15 @@ +// 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. + /// + public class BindingBrokenException : Exception + { + } +} diff --git a/src/Avalonia.Base/Data/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index e92ea8422a..0f587b969e 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -181,6 +181,21 @@ namespace Avalonia.Data 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. /// diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 411effd212..334add960e 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -41,6 +41,7 @@ Properties\SharedAssemblyInfo.cs + diff --git a/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs index d0133f161e..feca16a4b4 100644 --- a/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/EmptyExpressionNode.cs @@ -8,6 +8,8 @@ namespace Avalonia.Markup.Data { internal class EmptyExpressionNode : ExpressionNode { + public override string Description => "."; + protected override IObservable StartListening(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 75c4422962..d6f2d66adf 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -19,6 +19,7 @@ namespace Avalonia.Markup.Data private IObserver _observer; private IDisposable _valuePluginSubscription; + public abstract string Description { get; } public ExpressionNode Next { get; set; } public WeakReference Target @@ -91,6 +92,8 @@ namespace Avalonia.Markup.Data protected virtual void NextValueChanged(object value) { + var bindingBroken = BindingNotification.ExtractError(value) as MarkupBindingBrokenException; + bindingBroken?.Nodes.Add(Description); _observer.OnNext(value); } @@ -177,8 +180,10 @@ namespace Avalonia.Markup.Data private BindingNotification TargetNullNotification() { - // TODO: Work out a way to give a more useful error message here. - return new BindingNotification(new NullReferenceException(), BindingErrorType.Error, AvaloniaProperty.UnsetValue); + return new BindingNotification( + new MarkupBindingBrokenException(this), + BindingErrorType.Error, + AvaloniaProperty.UnsetValue); } } } diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 486ee59469..f3f175e04c 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -182,7 +182,7 @@ namespace Avalonia.Markup.Data .Publish(UninitializedValue) .RefCount() .Where(x => x != UninitializedValue) - .Select(FromWeakReference); + .Select(Translate); } return _result.Subscribe(observer); @@ -205,9 +205,26 @@ namespace Avalonia.Markup.Data return o is BindingNotification ? o : new WeakReference(o); } - private static object FromWeakReference(object o) + private object Translate(object o) { - return o is WeakReference ? ((WeakReference)o).Target : o; + var weak = o as WeakReference; + + if (weak != null) + { + return weak.Target; + } + else + { + var notification = o as BindingNotification; + var broken = notification.Error as MarkupBindingBrokenException; + + if (broken != null) + { + broken.Expression = Expression; + } + + return notification; + } } private IDisposable StartRoot() diff --git a/src/Markup/Avalonia.Markup/Data/IndexerNode.cs b/src/Markup/Avalonia.Markup/Data/IndexerNode.cs index f9615ee804..a88a673ca5 100644 --- a/src/Markup/Avalonia.Markup/Data/IndexerNode.cs +++ b/src/Markup/Avalonia.Markup/Data/IndexerNode.cs @@ -21,6 +21,8 @@ namespace Avalonia.Markup.Data Arguments = arguments; } + public override string Description => "[" + string.Join(",", Arguments) + "]"; + protected override IObservable StartListening(WeakReference reference) { var target = reference.Target; diff --git a/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs b/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs index 0d96830024..58a7915254 100644 --- a/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs +++ b/src/Markup/Avalonia.Markup/Data/LogicalNotNode.cs @@ -9,6 +9,8 @@ namespace Avalonia.Markup.Data { internal class LogicalNotNode : ExpressionNode { + public override string Description => "!"; + protected override void NextValueChanged(object value) { base.NextValueChanged(Negate(value)); diff --git a/src/Markup/Avalonia.Markup/Data/MarkupBindingBrokenException.cs b/src/Markup/Avalonia.Markup/Data/MarkupBindingBrokenException.cs new file mode 100644 index 0000000000..33aef139a0 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/MarkupBindingBrokenException.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Data; + +namespace Avalonia.Markup.Data +{ + public class MarkupBindingBrokenException : BindingBrokenException + { + private string _message; + + public MarkupBindingBrokenException() + { + } + + public MarkupBindingBrokenException(string message) + { + _message = message; + } + + internal MarkupBindingBrokenException(ExpressionNode node) + { + Nodes.Add(node.Description); + } + + public override string Message + { + get + { + if (_message != null) + { + return _message; + } + else + { + return _message = BuildMessage(); + } + } + } + + internal string Expression { get; set; } + internal IList Nodes { get; } = new List(); + + private string BuildMessage() + { + if (Nodes.Count == 0) + { + return "The binding chain was broken."; + } + else if (Nodes.Count == 1) + { + return $"'{Nodes[0]}' is null in expression '{Expression}'."; + } + else + { + var brokenPath = string.Join(".", Nodes.Skip(1).Reverse()) + .Replace(".!", "!") + .Replace(".[", "["); + return $"'{brokenPath}' is null in expression '{Expression}'."; + } + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs index ebeebcd07d..47da0c86b6 100644 --- a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs +++ b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs @@ -21,8 +21,8 @@ namespace Avalonia.Markup.Data _enableValidation = enableValidation; } + public override string Description => PropertyName; public string PropertyName { get; } - public Type PropertyType => _accessor?.PropertyType; public bool SetTargetValue(object value, BindingPriority priority) diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index cdcaeda4cc..1815d82c12 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -143,7 +143,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal(new[] { new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Inner' is null in expression 'Inner.MustBePositive'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), }, result); diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index b65dd091e0..bd7f28f620 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -66,7 +66,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal( new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), result); @@ -81,7 +81,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal( new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), result); @@ -96,7 +96,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal( new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), result); @@ -111,7 +111,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal( new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), result); @@ -166,7 +166,7 @@ namespace Avalonia.Markup.UnitTests.Data new[] { new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Foo' is null in expression 'Foo.Bar.Baz'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), }, @@ -284,7 +284,7 @@ namespace Avalonia.Markup.UnitTests.Data { "bar", new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Next' is null in expression 'Next.Bar'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), "baz" @@ -483,7 +483,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal( new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), result); @@ -511,7 +511,7 @@ namespace Avalonia.Markup.UnitTests.Data "foo", "bar", new BindingNotification( - new NullReferenceException(), + new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), BindingErrorType.Error, AvaloniaProperty.UnsetValue), }, diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs index 029285341b..ed7a6bddc3 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs @@ -43,7 +43,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml pv.Length == 3 && pv[0] is ProgressBar && object.ReferenceEquals(pv[1], ProgressBar.ValueProperty) && - (string)pv[2] == "Object reference not set to an instance of an object. | " + + (string)pv[2] == "'Value' is null in expression 'Value'. | " + "Could not convert FallbackValue 'bar' to 'System.Double'") { called = true; From 7206580175e79462b0eac8eea89dbf463cb48607 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 01:58:45 +0200 Subject: [PATCH 54/60] Filter out broken binding errors in TextBox. --- src/Avalonia.Controls/TextBox.cs | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 1003fef85f..dbea94f9a4 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -486,26 +486,28 @@ namespace Avalonia.Controls if (property == TextProperty) { var classes = (IPseudoClasses)Classes; - - if (status.ErrorType == BindingErrorType.None) - { - classes.Remove(":error"); - DataValidationErrors = null; - } - else - { - classes.Add(":error"); - DataValidationErrors = UnpackException(status.Error); - } + DataValidationErrors = UnpackException(status.Error); + classes.Set(":error", DataValidationErrors != null); } } private static IEnumerable UnpackException(Exception exception) { - var aggregate = exception as AggregateException; - return aggregate == null ? - (IEnumerable)new[] { exception } : - aggregate.InnerExceptions; + if (exception != null) + { + var aggregate = exception as AggregateException; + var exceptions = aggregate == null ? + (IEnumerable)new[] { exception } : + aggregate.InnerExceptions; + var filtered = exceptions.Where(x => !(x is BindingBrokenException)).ToList(); + + if (filtered.Count > 0) + { + return filtered; + } + } + + return null; } private int CoerceCaretIndex(int value) From 7cb001801acec654751233a5a65c56ae0c865bd6 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 15:01:30 +0200 Subject: [PATCH 55/60] Try to improve logged binding errors. - Don't log an error when the target for the root ExpressionNode is null. This is usually because the `DataContext` hasn't been set up yet and it spewed a load of useless error messages. - Add a Description field to `ExpressionObserver` that can be used in the case of e.g. #control bindings to record the whole expression (with the "#control" part) rather than just the part tracked by the `ExpressionObserver`. --- src/Avalonia.Base/Avalonia.Base.csproj | 2 +- .../Data/BindingBrokenException.cs | 15 --- .../Data/BindingChainNullException.cs | 85 ++++++++++++++ src/Avalonia.Base/Data/BindingNotification.cs | 2 +- src/Avalonia.Controls/TextBox.cs | 2 +- .../Avalonia.Markup.Xaml/Data/Binding.cs | 4 +- .../Avalonia.Markup/Avalonia.Markup.csproj | 2 +- .../Avalonia.Markup/Data/ExpressionNode.cs | 6 +- .../Data/ExpressionObserver.cs | 50 +++++++-- .../Data/MarkupBindingBrokenException.cs | 65 ----------- .../Data/MarkupBindingChainNullException.cs | 33 ++++++ .../ExpressionObserverTests_DataValidation.cs | 2 +- .../Data/ExpressionObserverTests_Property.cs | 104 ++++++++---------- .../Xaml/ControlBindingTests.cs | 3 +- 14 files changed, 216 insertions(+), 159 deletions(-) delete mode 100644 src/Avalonia.Base/Data/BindingBrokenException.cs create mode 100644 src/Avalonia.Base/Data/BindingChainNullException.cs delete mode 100644 src/Markup/Avalonia.Markup/Data/MarkupBindingBrokenException.cs create mode 100644 src/Markup/Avalonia.Markup/Data/MarkupBindingChainNullException.cs diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index 97dfd1230d..6674e45fd1 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -43,7 +43,7 @@ Properties\SharedAssemblyInfo.cs - + diff --git a/src/Avalonia.Base/Data/BindingBrokenException.cs b/src/Avalonia.Base/Data/BindingBrokenException.cs deleted file mode 100644 index 057629edb8..0000000000 --- a/src/Avalonia.Base/Data/BindingBrokenException.cs +++ /dev/null @@ -1,15 +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 -{ - /// - /// An exception returned through signalling that a - /// requested binding expression could not be evaluated. - /// - public class BindingBrokenException : Exception - { - } -} 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/BindingNotification.cs b/src/Avalonia.Base/Data/BindingNotification.cs index 0f587b969e..ecaf59e174 100644 --- a/src/Avalonia.Base/Data/BindingNotification.cs +++ b/src/Avalonia.Base/Data/BindingNotification.cs @@ -267,7 +267,7 @@ namespace Avalonia.Data case BindingErrorType.None: return $"{{Value: {Value}}}"; default: - return HasValue ? + return HasValue ? $"{{{ErrorType}: {Error}, Fallback: {Value}}}" : $"{{{ErrorType}: {Error}}}"; } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index dbea94f9a4..2c43f8f97e 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -499,7 +499,7 @@ namespace Avalonia.Controls var exceptions = aggregate == null ? (IEnumerable)new[] { exception } : aggregate.InnerExceptions; - var filtered = exceptions.Where(x => !(x is BindingBrokenException)).ToList(); + var filtered = exceptions.Where(x => !(x is BindingChainNullException)).ToList(); if (filtered.Count > 0) { diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index 6b8b1282e5..832a25be27 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs @@ -238,10 +238,12 @@ namespace Avalonia.Markup.Xaml.Data { Contract.Requires(target != null); + var description = $"#{elementName}.{path}"; var result = new ExpressionObserver( ControlLocator.Track(target, elementName), path, - false); + false, + description); return result; } diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 334add960e..9edfd2957d 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -41,7 +41,7 @@ Properties\SharedAssemblyInfo.cs - + diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs index d6f2d66adf..0e7777b732 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -92,8 +92,8 @@ namespace Avalonia.Markup.Data protected virtual void NextValueChanged(object value) { - var bindingBroken = BindingNotification.ExtractError(value) as MarkupBindingBrokenException; - bindingBroken?.Nodes.Add(Description); + var bindingBroken = BindingNotification.ExtractError(value) as MarkupBindingChainNullException; + bindingBroken?.AddNode(Description); _observer.OnNext(value); } @@ -181,7 +181,7 @@ namespace Avalonia.Markup.Data private BindingNotification TargetNullNotification() { return new BindingNotification( - new MarkupBindingBrokenException(this), + 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 f3f175e04c..819949b7b9 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -63,7 +63,14 @@ namespace Avalonia.Markup.Data /// The root object. /// The expression. /// Whether data validation should be enabled. - public ExpressionObserver(object root, string expression, bool enableDataValidation = false) + /// + /// 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); @@ -73,6 +80,7 @@ namespace Avalonia.Markup.Data } Expression = expression; + Description = description ?? expression; _node = Parse(expression, enableDataValidation); _root = new WeakReference(root); } @@ -83,15 +91,20 @@ namespace Avalonia.Markup.Data /// An observable which provides the root object. /// The expression. /// Whether data validation should be enabled. + /// + /// A description of the expression. If null, will be used. + /// public ExpressionObserver( IObservable rootObservable, string expression, - bool enableDataValidation = false) + bool enableDataValidation = false, + string description = null) { Contract.Requires(rootObservable != null); Contract.Requires(expression != null); Expression = expression; + Description = description ?? expression; _node = Parse(expression, enableDataValidation); _finished = new Subject(); _root = rootObservable; @@ -104,17 +117,22 @@ namespace Avalonia.Markup.Data /// The expression. /// An observable which triggers a re-read of the getter. /// 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 enableDataValidation = false) + bool enableDataValidation = false, + string description = null) { Contract.Requires(rootGetter != null); Contract.Requires(expression != null); Contract.Requires(update != null); Expression = expression; + Description = description ?? expression; _node = Parse(expression, enableDataValidation); _finished = new Subject(); @@ -138,6 +156,11 @@ namespace Avalonia.Markup.Data 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. /// @@ -149,9 +172,6 @@ namespace Avalonia.Markup.Data /// public Type ResultType => (Leaf as PropertyAccessorNode)?.PropertyType; - /// - string IDescription.Description => Expression; - /// /// Gets the leaf node. /// @@ -215,15 +235,23 @@ namespace Avalonia.Markup.Data } else { - var notification = o as BindingNotification; - var broken = notification.Error as MarkupBindingBrokenException; + var broken = BindingNotification.ExtractError(o) as MarkupBindingChainNullException; if (broken != null) { - broken.Expression = Expression; + // 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) + { + broken.Commit(Description); + } + else + { + o = AvaloniaProperty.UnsetValue; + } } - - return notification; + return o; } } diff --git a/src/Markup/Avalonia.Markup/Data/MarkupBindingBrokenException.cs b/src/Markup/Avalonia.Markup/Data/MarkupBindingBrokenException.cs deleted file mode 100644 index 33aef139a0..0000000000 --- a/src/Markup/Avalonia.Markup/Data/MarkupBindingBrokenException.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Avalonia.Data; - -namespace Avalonia.Markup.Data -{ - public class MarkupBindingBrokenException : BindingBrokenException - { - private string _message; - - public MarkupBindingBrokenException() - { - } - - public MarkupBindingBrokenException(string message) - { - _message = message; - } - - internal MarkupBindingBrokenException(ExpressionNode node) - { - Nodes.Add(node.Description); - } - - public override string Message - { - get - { - if (_message != null) - { - return _message; - } - else - { - return _message = BuildMessage(); - } - } - } - - internal string Expression { get; set; } - internal IList Nodes { get; } = new List(); - - private string BuildMessage() - { - if (Nodes.Count == 0) - { - return "The binding chain was broken."; - } - else if (Nodes.Count == 1) - { - return $"'{Nodes[0]}' is null in expression '{Expression}'."; - } - else - { - var brokenPath = string.Join(".", Nodes.Skip(1).Reverse()) - .Replace(".!", "!") - .Replace(".[", "["); - return $"'{brokenPath}' is null in expression '{Expression}'."; - } - } - } -} 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/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs index 1815d82c12..fb98144647 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_DataValidation.cs @@ -143,7 +143,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal(new[] { new BindingNotification( - new MarkupBindingBrokenException("'Inner' is null in expression 'Inner.MustBePositive'."), + new MarkupBindingChainNullException("Inner.MustBePositive", "Inner"), BindingErrorType.Error, AvaloniaProperty.UnsetValue), }, result); diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs index bd7f28f620..aa9ee7d58b 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Property.cs @@ -58,63 +58,43 @@ namespace Avalonia.Markup.UnitTests.Data } [Fact] - public async void Should_Return_BindingNotification_Error_For_Root_Null() + 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( - new BindingNotification( - new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), - BindingErrorType.Error, - AvaloniaProperty.UnsetValue), - result); + Assert.Equal(AvaloniaProperty.UnsetValue, result); } [Fact] - public async void Should_Return_BindingNotification_Error_For_Root_UnsetValue() + 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( - new BindingNotification( - new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), - BindingErrorType.Error, - AvaloniaProperty.UnsetValue), - result); + Assert.Equal(AvaloniaProperty.UnsetValue, result); } [Fact] - public async void Should_Return_BindingNotification_Error_For_Observable_Root_Null() + 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( - new BindingNotification( - new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), - BindingErrorType.Error, - AvaloniaProperty.UnsetValue), - result); + Assert.Equal(AvaloniaProperty.UnsetValue, result); } [Fact] - public async void Should_Return_BindingNotification_Error_For_Observable_Root_UnsetValue() + 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( - new BindingNotification( - new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), - BindingErrorType.Error, - AvaloniaProperty.UnsetValue), - result); + Assert.Equal(AvaloniaProperty.UnsetValue, result); } [Fact] @@ -166,7 +146,7 @@ namespace Avalonia.Markup.UnitTests.Data new[] { new BindingNotification( - new MarkupBindingBrokenException("'Foo' is null in expression 'Foo.Bar.Baz'."), + new MarkupBindingChainNullException("Foo.Bar.Baz", "Foo"), BindingErrorType.Error, AvaloniaProperty.UnsetValue), }, @@ -270,24 +250,34 @@ namespace Avalonia.Markup.UnitTests.Data [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 object[] { "bar", new BindingNotification( - new MarkupBindingBrokenException("'Next' is null in expression 'Next.Bar'."), + new MarkupBindingChainNullException("Next.Next.Bar", "Next.Next"), BindingErrorType.Error, AvaloniaProperty.UnsetValue), - "baz" + "bar" }, result); @@ -299,7 +289,7 @@ namespace Avalonia.Markup.UnitTests.Data } [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"); @@ -311,10 +301,16 @@ 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(); @@ -475,20 +471,6 @@ namespace Avalonia.Markup.UnitTests.Data } } - [Fact] - public async void Should_Handle_Null_Root() - { - var target = new ExpressionObserver((object)null, "Foo"); - var result = await target.Take(1); - - Assert.Equal( - new BindingNotification( - new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), - BindingErrorType.Error, - AvaloniaProperty.UnsetValue), - result); - } - [Fact] public void Can_Replace_Root() { @@ -510,10 +492,7 @@ namespace Avalonia.Markup.UnitTests.Data { "foo", "bar", - new BindingNotification( - new MarkupBindingBrokenException("'Foo' is null in expression 'Foo'."), - BindingErrorType.Error, - AvaloniaProperty.UnsetValue), + AvaloniaProperty.UnsetValue, }, result); @@ -580,6 +559,7 @@ namespace Avalonia.Markup.UnitTests.Data private class Class2 : NotifyingBase, INext { private string _bar; + private INext _next; public string Bar { @@ -590,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.Xaml.UnitTests/Xaml/ControlBindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs index ed7a6bddc3..df62a1ed41 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlBindingTests.cs @@ -43,8 +43,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml pv.Length == 3 && pv[0] is ProgressBar && object.ReferenceEquals(pv[1], ProgressBar.ValueProperty) && - (string)pv[2] == "'Value' is null in expression 'Value'. | " + - "Could not convert FallbackValue 'bar' to 'System.Double'") + (string)pv[2] == "Could not convert FallbackValue 'bar' to 'System.Double'") { called = true; } From c9d3408c290360428c1887a9ac81e17a49a3893c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 15:53:35 +0200 Subject: [PATCH 56/60] Improve numerical binding error messages. --- samples/BindingTest/MainWindow.xaml | 1 + .../DataAnnotationsErrorViewModel.cs | 3 ++ src/Avalonia.Base/AvaloniaObject.cs | 2 +- src/Avalonia.Base/PriorityValue.cs | 2 +- src/Avalonia.Base/Utilities/TypeUtilities.cs | 41 +++++++++++++++++++ .../Avalonia.Markup/Data/ExpressionSubject.cs | 3 -- .../Avalonia.Markup/DefaultValueConverter.cs | 12 +++++- .../AvaloniaObjectTests_Binding.cs | 2 +- .../AvaloniaObjectTests_Direct.cs | 2 +- .../Data/ExpressionSubjectTests.cs | 6 +-- .../Xaml/ControlBindingTests.cs | 2 +- 11 files changed, 64 insertions(+), 12 deletions(-) diff --git a/samples/BindingTest/MainWindow.xaml b/samples/BindingTest/MainWindow.xaml index f0bb169f3c..02c364346d 100644 --- a/samples/BindingTest/MainWindow.xaml +++ b/samples/BindingTest/MainWindow.xaml @@ -82,6 +82,7 @@ + diff --git a/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs b/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs index b622ee9e18..634498c165 100644 --- a/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs +++ b/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs @@ -10,5 +10,8 @@ namespace BindingTest.ViewModels [Phone] [MaxLength(10)] public string PhoneNumber { get; set; } + + [Range(0, 9)] + public int LessThan10 { get; set; } } } diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 0f2afc6474..eeaf782e83 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -667,7 +667,7 @@ namespace Avalonia Logger.Error( LogArea.Binding, this, - "Error binding to {Target}.{Property}: {Message}", + "Error in binding to {Target}.{Property}: {Message}", this, property, ExceptionUtilities.GetMessage(notification.Error)); diff --git a/src/Avalonia.Base/PriorityValue.cs b/src/Avalonia.Base/PriorityValue.cs index 11bd6c61cd..a7eb4465b3 100644 --- a/src/Avalonia.Base/PriorityValue.cs +++ b/src/Avalonia.Base/PriorityValue.cs @@ -189,7 +189,7 @@ namespace Avalonia LogEventLevel.Error, LogArea.Binding, _owner, - "Error binding to {Target}.{Property}: {Message}", + "Error in binding to {Target}.{Property}: {Message}", _owner, Property, error.Error.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/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs index 85609c247c..cb587c683b 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs @@ -139,9 +139,6 @@ namespace Avalonia.Markup.Data "IValueConverter should not return non-errored BindingNotification."); } - notification.Error = new InvalidCastException( - $"Error setting '{_inner.Expression}': {notification.Error.Message}"); - notification.ErrorType = BindingErrorType.Error; _errors.OnNext(notification); if (_fallbackValue != AvaloniaProperty.UnsetValue) diff --git a/src/Markup/Avalonia.Markup/DefaultValueConverter.cs b/src/Markup/Avalonia.Markup/DefaultValueConverter.cs index cde73b67a1..86d37d8e13 100644 --- a/src/Markup/Avalonia.Markup/DefaultValueConverter.cs +++ b/src/Markup/Avalonia.Markup/DefaultValueConverter.cs @@ -43,7 +43,17 @@ namespace Avalonia.Markup if (value != null) { - var message = $"Could not convert '{value}' to '{targetType}'"; + 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); } diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs index 1757550d4a..66fe3c7767 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs @@ -332,7 +332,7 @@ namespace Avalonia.Base.UnitTests 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) => { diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs index 51d5d19834..26e911f586 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs @@ -437,7 +437,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) && diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs index 5498926fe4..1af35d692b 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs @@ -123,7 +123,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal( new BindingNotification( - new InvalidCastException("Could not convert 'foo' to 'System.Int32'"), + new InvalidCastException("'foo' is not a valid number."), BindingErrorType.Error, 42), result); @@ -144,7 +144,7 @@ namespace Avalonia.Markup.UnitTests.Data Assert.Equal( new BindingNotification( - new InvalidCastException("Could not convert 'foo' to 'System.Int32'"), + new InvalidCastException("'foo' is not a valid number."), BindingErrorType.Error, 42), result); @@ -294,7 +294,7 @@ namespace Avalonia.Markup.UnitTests.Data new BindingNotification("1.2"), new BindingNotification("3.4"), new BindingNotification( - new InvalidCastException("Error setting 'DoubleValue': Could not convert 'bar' to 'System.Double'"), + new InvalidCastException("'bar' is not a valid number."), BindingErrorType.Error) }, result); 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) && From 079d481e364f89ccafce5be33848526e5144c3db Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 Aug 2016 20:19:41 +0200 Subject: [PATCH 57/60] Renamed ExpressionSubject -> BindingExpression. `ExpressionSubject` wasn't a good name as the important thing it isn't convert an `ExpressionObserver` into an `ISubject`. The important thing it does is insert an `IValueConverter` into the pipeline. --- .../Avalonia.Markup.Xaml/Data/Binding.cs | 2 +- .../Avalonia.Markup/Avalonia.Markup.csproj | 2 +- ...ressionSubject.cs => BindingExpression.cs} | 12 ++--- .../Avalonia.Markup.UnitTests.csproj | 2 +- ...jectTests.cs => BindingExpressionTests.cs} | 49 +++++++++---------- .../Data/BindingTests.cs | 6 +-- .../Data/BindingTests_DataValidation.cs | 10 ++-- 7 files changed, 39 insertions(+), 44 deletions(-) rename src/Markup/Avalonia.Markup/Data/{ExpressionSubject.cs => BindingExpression.cs} (96%) rename tests/Avalonia.Markup.UnitTests/Data/{ExpressionSubjectTests.cs => BindingExpressionTests.cs} (89%) diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index 832a25be27..086257f24c 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs @@ -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, diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 9edfd2957d..738f381f4e 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -46,7 +46,7 @@ - + diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/BindingExpression.cs similarity index 96% rename from src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs rename to src/Markup/Avalonia.Markup/Data/BindingExpression.cs index cb587c683b..0f4c091bff 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/BindingExpression.cs @@ -12,10 +12,10 @@ using Avalonia.Utilities; namespace Avalonia.Markup.Data { /// - /// Turns an into a subject that can be bound two-way with - /// a value converter. + /// Binds to an expression on an object using a type value converter to convert the values + /// that are send and received. /// - public class ExpressionSubject : ISubject, IDescription + public class BindingExpression : ISubject, IDescription { private readonly ExpressionObserver _inner; private readonly Type _targetType; @@ -28,7 +28,7 @@ namespace Avalonia.Markup.Data /// /// The . /// The type to convert the value to. - public ExpressionSubject(ExpressionObserver inner, Type targetType) + public BindingExpression(ExpressionObserver inner, Type targetType) : this(inner, targetType, DefaultValueConverter.Instance) { } @@ -43,7 +43,7 @@ namespace Avalonia.Markup.Data /// A parameter to pass to . /// /// The binding priority. - public ExpressionSubject( + public BindingExpression( ExpressionObserver inner, Type targetType, IValueConverter converter, @@ -66,7 +66,7 @@ namespace Avalonia.Markup.Data /// A parameter to pass to . /// /// The binding priority. - public ExpressionSubject( + public BindingExpression( ExpressionObserver inner, Type targetType, object fallbackValue, diff --git a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj index 55160f1698..66d234ac19 100644 --- a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj +++ b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj @@ -111,7 +111,7 @@ - + diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/BindingExpressionTests.cs similarity index 89% rename from tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs rename to tests/Avalonia.Markup.UnitTests/Data/BindingExpressionTests.cs index 1af35d692b..c53dc417b0 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/BindingExpressionTests.cs @@ -2,26 +2,25 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.ComponentModel; +using System.Collections.Generic; using System.Globalization; using System.Reactive.Linq; -using Moq; +using System.Threading; using Avalonia.Data; using Avalonia.Markup.Data; -using Xunit; -using System.Threading; -using System.Collections.Generic; using Avalonia.UnitTests; +using Moq; +using Xunit; namespace Avalonia.Markup.UnitTests.Data { - public class ExpressionSubjectTests + public class BindingExpressionTests { [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 target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(string)); var result = await target.Take(1); Assert.Equal("foo", result); @@ -31,7 +30,7 @@ namespace Avalonia.Markup.UnitTests.Data public void Should_Set_Simple_Property_Value() { var data = new Class1 { StringValue = "foo" }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(string)); + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(string)); target.OnNext("bar"); @@ -44,7 +43,7 @@ namespace Avalonia.Markup.UnitTests.Data Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; var data = new Class1 { StringValue = "5.6" }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(double)); + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double)); var result = await target.Take(1); Assert.Equal(5.6, result); @@ -54,7 +53,7 @@ namespace Avalonia.Markup.UnitTests.Data 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 target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double)); var result = await target.Take(1); Assert.IsType(result); @@ -64,7 +63,7 @@ namespace Avalonia.Markup.UnitTests.Data 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 target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double)); var result = await target.Take(1); Assert.Equal(AvaloniaProperty.UnsetValue, result); @@ -76,7 +75,7 @@ namespace Avalonia.Markup.UnitTests.Data Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; var data = new Class1 { StringValue = (5.6).ToString() }; - var target = new ExpressionSubject(new ExpressionObserver(data, "StringValue"), typeof(double)); + var target = new BindingExpression(new ExpressionObserver(data, "StringValue"), typeof(double)); target.OnNext(6.7); @@ -89,7 +88,7 @@ namespace Avalonia.Markup.UnitTests.Data Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue"), typeof(string)); + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); var result = await target.Take(1); Assert.Equal((5.6).ToString(), result); @@ -101,7 +100,7 @@ namespace Avalonia.Markup.UnitTests.Data Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue"), typeof(string)); + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); target.OnNext("6.7"); @@ -114,7 +113,7 @@ namespace Avalonia.Markup.UnitTests.Data Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; var data = new Class1 { StringValue = "foo" }; - var target = new ExpressionSubject( + var target = new BindingExpression( new ExpressionObserver(data, "StringValue"), typeof(int), 42, @@ -135,7 +134,7 @@ namespace Avalonia.Markup.UnitTests.Data Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; var data = new Class1 { StringValue = "foo" }; - var target = new ExpressionSubject( + var target = new BindingExpression( new ExpressionObserver(data, "StringValue", true), typeof(int), 42, @@ -156,7 +155,7 @@ namespace Avalonia.Markup.UnitTests.Data Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; var data = new Class1 { StringValue = "foo" }; - var target = new ExpressionSubject( + var target = new BindingExpression( new ExpressionObserver(data, "StringValue"), typeof(int), "bar", @@ -178,7 +177,7 @@ namespace Avalonia.Markup.UnitTests.Data Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; var data = new Class1 { StringValue = "foo" }; - var target = new ExpressionSubject( + var target = new BindingExpression( new ExpressionObserver(data, "StringValue", true), typeof(int), "bar", @@ -198,7 +197,7 @@ namespace Avalonia.Markup.UnitTests.Data 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)); + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); target.OnNext("foo"); @@ -209,7 +208,7 @@ namespace Avalonia.Markup.UnitTests.Data public void Setting_Invalid_Double_String_Should_Use_FallbackValue() { var data = new Class1 { DoubleValue = 5.6 }; - var target = new ExpressionSubject( + var target = new BindingExpression( new ExpressionObserver(data, "DoubleValue"), typeof(string), "9.8", @@ -224,7 +223,7 @@ namespace Avalonia.Markup.UnitTests.Data 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)); + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); target.OnNext(null); @@ -235,7 +234,7 @@ namespace Avalonia.Markup.UnitTests.Data 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)); + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue"), typeof(string)); target.OnNext(AvaloniaProperty.UnsetValue); @@ -247,7 +246,7 @@ namespace Avalonia.Markup.UnitTests.Data { var data = new Class1 { DoubleValue = 5.6 }; var converter = new Mock(); - var target = new ExpressionSubject( + var target = new BindingExpression( new ExpressionObserver(data, "DoubleValue"), typeof(string), converter.Object, @@ -263,7 +262,7 @@ namespace Avalonia.Markup.UnitTests.Data { var data = new Class1 { DoubleValue = 5.6 }; var converter = new Mock(); - var target = new ExpressionSubject( + var target = new BindingExpression( new ExpressionObserver(data, "DoubleValue"), typeof(string), converter.Object, @@ -279,7 +278,7 @@ namespace Avalonia.Markup.UnitTests.Data { var data = new Class1 { DoubleValue = 5.6 }; var converter = new Mock(); - var target = new ExpressionSubject(new ExpressionObserver(data, "DoubleValue", true), typeof(string)); + var target = new BindingExpression(new ExpressionObserver(data, "DoubleValue", true), typeof(string)); var result = new List(); target.Subscribe(x => result.Add(x)); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs index b07a031e15..210ad2ab0b 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs @@ -200,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] @@ -216,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] @@ -233,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] diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs index 4f175ccf5b..5dd8d0cdf9 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests_DataValidation.cs @@ -2,15 +2,11 @@ // 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; using System.Reactive.Linq; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Markup.Data; using Avalonia.Markup.Xaml.Data; -using Moq; -using ReactiveUI; using Xunit; namespace Avalonia.Markup.Xaml.UnitTests.Data @@ -27,7 +23,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data var target = new Binding(nameof(Class1.Foo)); var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: false); - var subject = (ExpressionSubject)instanced.Subject; + var subject = (BindingExpression)instanced.Subject; object result = null; subject.Subscribe(x => result = x); @@ -45,7 +41,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data var target = new Binding(nameof(Class1.Foo)); var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: true); - var subject = (ExpressionSubject)instanced.Subject; + var subject = (BindingExpression)instanced.Subject; object result = null; subject.Subscribe(x => result = x); @@ -63,7 +59,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data var target = new Binding(nameof(Class1.Foo)) { Priority = BindingPriority.TemplatedParent }; var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: true); - var subject = (ExpressionSubject)instanced.Subject; + var subject = (BindingExpression)instanced.Subject; object result = null; subject.Subscribe(x => result = x); From 7f2cbd0b7131a1b0db4849b7230e9afc560e0aca Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 20 Aug 2016 01:43:42 +0200 Subject: [PATCH 58/60] Use a path for the TextBox error icon. --- src/Avalonia.Controls/Canvas.cs | 1 + src/Avalonia.Themes.Default/TextBox.xaml | 13 +++++-------- 2 files changed, 6 insertions(+), 8 deletions(-) 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.Themes.Default/TextBox.xaml b/src/Avalonia.Themes.Default/TextBox.xaml index 262c271e85..8a5a41845b 100644 --- a/src/Avalonia.Themes.Default/TextBox.xaml +++ b/src/Avalonia.Themes.Default/TextBox.xaml @@ -29,15 +29,12 @@ - + - - - - - + + - -