diff --git a/src/DynamoCoreWpf/Controls/DynamoTextBox.cs b/src/DynamoCoreWpf/Controls/DynamoTextBox.cs index fd2560a6007..e5ba4fe151b 100644 --- a/src/DynamoCoreWpf/Controls/DynamoTextBox.cs +++ b/src/DynamoCoreWpf/Controls/DynamoTextBox.cs @@ -223,6 +223,7 @@ private void UpdateDataSource(bool recordForUndo) if (expr.HasValidationError && nvm != null) { + nvm.NodeModel.ClearErrorsAndWarnings(); nvm.NodeModel.Error(expr.ValidationError.ErrorContent as string); } } diff --git a/src/Libraries/CoreNodeModels/Input/BaseTypes.cs b/src/Libraries/CoreNodeModels/Input/BaseTypes.cs index 826b33c064a..5c47d677e92 100644 --- a/src/Libraries/CoreNodeModels/Input/BaseTypes.cs +++ b/src/Libraries/CoreNodeModels/Input/BaseTypes.cs @@ -337,6 +337,8 @@ protected override void DeserializeCore(XmlElement element, SaveContext context) { base.DeserializeCore(element, context); //Base implementation must be called + ClearErrorsAndWarnings(); + foreach ( XmlNode subNode in element.ChildNodes.Cast() @@ -344,6 +346,9 @@ XmlNode subNode in { Value = subNode.Attributes[0].Value; } + + // Value's equality guard can skip notify, force UI to drop uncommitted invalid text + RaisePropertyChanged(nameof(Value)); } #endregion diff --git a/src/Libraries/CoreNodeModels/Input/DateTime.cs b/src/Libraries/CoreNodeModels/Input/DateTime.cs index daadb8d2d5e..63ad0dcdfd9 100644 --- a/src/Libraries/CoreNodeModels/Input/DateTime.cs +++ b/src/Libraries/CoreNodeModels/Input/DateTime.cs @@ -1,10 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Globalization; using Dynamo.Configuration; +using Dynamo.Graph; using Dynamo.Graph.Nodes; using Newtonsoft.Json; using ProtoCore.AST.AssociativeAST; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Xml; namespace CoreNodeModels.Input { @@ -23,6 +25,27 @@ private DateTime(IEnumerable inPorts, IEnumerable outPorts ShouldDisplayPreviewCore = false; } + /// + /// Display text for the DateTime input. Bound TwoWay so WPF validation can run; + /// the setter intentionally discard the value — commits go through UpdateModelValueCommand. + /// + [JsonIgnore] + public string ValueText + { + get => Value.ToString(PreferenceSettings.DefaultDateFormat, CultureInfo.InvariantCulture); + set => _ = value; + } + + public override System.DateTime Value + { + get { return base.Value; } + set + { + base.Value = value; + RaisePropertyChanged(nameof(ValueText)); + } + } + /// /// Constructor /// @@ -92,14 +115,53 @@ public override IEnumerable BuildOutputAst(List + /// Parses using + /// and the invariant culture. + /// + /// The date/time string to parse. + /// When this method returns, the parsed value if successful; otherwise default. + /// Return true if matches the expected format; otherwise false. + public static bool TryParseDateTime(string text, out System.DateTime parsed) + { + return System.DateTime.TryParseExact( + text, + PreferenceSettings.DefaultDateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out parsed); + } + protected override System.DateTime DeserializeValue(string val) { - System.DateTime result; - result = System.DateTime.TryParseExact(val, PreferenceSettings.DefaultDateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out result) ? - result : PreferenceSettings.DynamoDefaultTime; + var result = TryParseDateTime(val, out var parsed) ? parsed : PreferenceSettings.DynamoDefaultTime; return System.DateTime.SpecifyKind(result, DateTimeKind.Utc); } + protected override void DeserializeCore(XmlElement nodeElement, SaveContext context) + { + base.DeserializeCore(nodeElement, context); + ClearErrorsAndWarnings(); + } + protected override string SerializeValue() { return Value.ToString(PreferenceSettings.DefaultDateFormat, CultureInfo.InvariantCulture); diff --git a/src/Libraries/CoreNodeModels/Input/DoubleSlider.cs b/src/Libraries/CoreNodeModels/Input/DoubleSlider.cs index 6cc974bb8e0..5266198c08a 100644 --- a/src/Libraries/CoreNodeModels/Input/DoubleSlider.cs +++ b/src/Libraries/CoreNodeModels/Input/DoubleSlider.cs @@ -117,18 +117,22 @@ protected override bool UpdateValueCore(UpdateValueParams updateValueParams) { case "Min": case "MinText": + ClearErrorsAndWarnings(); Min = ConvertStringToDouble(value); return true; // UpdateValueCore handled. case "Max": case "MaxText": + ClearErrorsAndWarnings(); Max = ConvertStringToDouble(value); return true; // UpdateValueCore handled. case "Value": case "ValueText": + ClearErrorsAndWarnings(); Value = ConvertStringToDouble(value); return true; // UpdateValueCore handled. case "Step": case "StepText": + ClearErrorsAndWarnings(); Step = ConvertStringToDouble(value); return true; } @@ -154,6 +158,8 @@ protected override void DeserializeCore(XmlElement element, SaveContext context) { base.DeserializeCore(element, context); //Base implementation must be called. + ClearErrorsAndWarnings(); + foreach (XmlNode subNode in element.ChildNodes) { if (!subNode.Name.Equals("Range")) @@ -180,6 +186,9 @@ protected override void DeserializeCore(XmlElement element, SaveContext context) } } + // Value's equality guard can skip notify, force UI to drop uncommitted invalid text + RaisePropertyChanged(nameof(Value)); + break; } } diff --git a/src/Libraries/CoreNodeModels/Input/IntegerSlider.cs b/src/Libraries/CoreNodeModels/Input/IntegerSlider.cs index 7a6b1a94e30..546f9f79edd 100644 --- a/src/Libraries/CoreNodeModels/Input/IntegerSlider.cs +++ b/src/Libraries/CoreNodeModels/Input/IntegerSlider.cs @@ -291,7 +291,7 @@ public IntegerSlider64Bit() } // If the value field in the slider has a number greater than - // long.Maxvalue (or MinValue), the value will be changed to long.MaxValue (or MinValue) + // long.MaxValue (or MinValue), the value will be changed to long.MaxValue (or MinValue) // The property setter is overridden here to update the UI, in case the value is changed. public override long Value { @@ -315,19 +315,22 @@ protected override bool UpdateValueCore(UpdateValueParams updateValueParams) { case nameof(Min): case "MinText": + ClearErrorsAndWarnings(); Min = ConvertStringToInt64(value); return true; // UpdateValueCore handled. case nameof(Max): case "MaxText": + ClearErrorsAndWarnings(); Max = ConvertStringToInt64(value); return true; // UpdateValueCore handled. case nameof(Value): case "ValueText": - UpdateNodeInfo(value); + ClearErrorsAndWarnings(); Value = ConvertStringToInt64(value); return true; // UpdateValueCore handled. case nameof(Step): case "StepText": + ClearErrorsAndWarnings(); Step = ConvertStringToInt64(value); return true; } @@ -335,18 +338,6 @@ protected override bool UpdateValueCore(UpdateValueParams updateValueParams) return base.UpdateValueCore(updateValueParams); } - private void UpdateNodeInfo(string value) - { - if (IsValueInt64(value)) - { - ClearInfoMessages(); - } - else - { - Info(Resources.IntegerSliderInfoMessage, true); - } - } - public override IEnumerable BuildOutputAst(List inputAstNodes) { var rhs = AstFactory.BuildIntNode(Value); @@ -390,6 +381,8 @@ protected override void DeserializeCore(XmlElement element, SaveContext context) { base.DeserializeCore(element, context); //Base implementation must be called. + ClearErrorsAndWarnings(); + foreach (XmlNode subNode in element.ChildNodes) { if (!subNode.Name.Equals(nameof(Range))) diff --git a/src/Libraries/CoreNodeModels/Input/SliderBase.cs b/src/Libraries/CoreNodeModels/Input/SliderBase.cs index 9ffac94039c..f9c0feae0d8 100644 --- a/src/Libraries/CoreNodeModels/Input/SliderBase.cs +++ b/src/Libraries/CoreNodeModels/Input/SliderBase.cs @@ -160,23 +160,5 @@ protected static long ConvertStringToInt64(string value) } return result; } - - /// - /// check if the value is within int64 range - /// - /// - /// - protected static bool IsValueInt64(string value) - { - try - { - var result = Convert.ToInt64(value); - return true; - } - catch (OverflowException) - { - return false; - } - } } } \ No newline at end of file diff --git a/src/Libraries/CoreNodeModels/Properties/Resources.Designer.cs b/src/Libraries/CoreNodeModels/Properties/Resources.Designer.cs index 33037e201de..96cd19aa057 100644 --- a/src/Libraries/CoreNodeModels/Properties/Resources.Designer.cs +++ b/src/Libraries/CoreNodeModels/Properties/Resources.Designer.cs @@ -479,6 +479,15 @@ public static string DateTimeDescription { } } + /// + /// Looks up a localized string similar to The input must match the Date Time format and be a valid date/time.. + /// + public static string DateTimeNodeInputInvalidFormat { + get { + return ResourceManager.GetString("DateTimeNodeInputInvalidFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Validates the data type of an input and returns it. /// @@ -978,6 +987,15 @@ public static string IntegerSliderInfoMessage { } } + /// + /// Looks up a localized string similar to The input must be an integer.. + /// + public static string IntegerSliderInputMustBeInteger { + get { + return ResourceManager.GetString("IntegerSliderInputMustBeInteger", resourceCulture); + } + } + /// /// Looks up a localized string similar to Produces integer values. /// diff --git a/src/Libraries/CoreNodeModels/Properties/Resources.en-US.resx b/src/Libraries/CoreNodeModels/Properties/Resources.en-US.resx index c219535bbc0..c9700c491b6 100644 --- a/src/Libraries/CoreNodeModels/Properties/Resources.en-US.resx +++ b/src/Libraries/CoreNodeModels/Properties/Resources.en-US.resx @@ -584,6 +584,12 @@ The input must be numeric. + + The input must match the Date Time format and be a valid date/time. + + + The input must be an integer. + Select a Color from the palette diff --git a/src/Libraries/CoreNodeModels/Properties/Resources.resx b/src/Libraries/CoreNodeModels/Properties/Resources.resx index 2dd8afe3279..a155dceea97 100644 --- a/src/Libraries/CoreNodeModels/Properties/Resources.resx +++ b/src/Libraries/CoreNodeModels/Properties/Resources.resx @@ -584,6 +584,12 @@ The input must be numeric. + + The input must match the Date Time format and be a valid date/time. + + + The input must be an integer. + Select a Color from the palette diff --git a/src/Libraries/CoreNodeModelsWpf/Controls/DateTimeInputControl.xaml b/src/Libraries/CoreNodeModelsWpf/Controls/DateTimeInputControl.xaml index be6678c5ef6..51a052b6c51 100644 --- a/src/Libraries/CoreNodeModelsWpf/Controls/DateTimeInputControl.xaml +++ b/src/Libraries/CoreNodeModelsWpf/Controls/DateTimeInputControl.xaml @@ -1,16 +1,15 @@  - @@ -18,7 +17,8 @@ - - + Style="{StaticResource SZoomFadeTextBox}" > + - - + + diff --git a/src/Libraries/CoreNodeModelsWpf/Controls/DateTimeInputControl.xaml.cs b/src/Libraries/CoreNodeModelsWpf/Controls/DateTimeInputControl.xaml.cs index 041cc62e01f..ea29270cb9d 100644 --- a/src/Libraries/CoreNodeModelsWpf/Controls/DateTimeInputControl.xaml.cs +++ b/src/Libraries/CoreNodeModelsWpf/Controls/DateTimeInputControl.xaml.cs @@ -1,5 +1,6 @@ -using System.Windows; -using System.Windows.Forms; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; using UserControl = System.Windows.Controls.UserControl; namespace CoreNodeModelsWpf.Controls @@ -12,15 +13,26 @@ public partial class DateTimeInputControl : UserControl public DateTimeInputControl() { InitializeComponent(); + Loaded += DateTimeInputControl_Loaded; } - private void ButtonBase_OnClick(object sender, RoutedEventArgs e) + private void DateTimeInputControl_Loaded(object sender, RoutedEventArgs e) { - var picker = new DateTimePicker + Loaded -= DateTimeInputControl_Loaded; + + var binding = new System.Windows.Data.Binding(nameof(CoreNodeModels.Input.DateTime.ValueText)) + { + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.Explicit, + NotifyOnValidationError = false + }; + var rule = new DateTimeValidationRule { - Format = DateTimePickerFormat.Time, - ShowUpDown = true, + ValidationStep = ValidationStep.RawProposedValue }; + binding.ValidationRules.Add(rule); + DateTimeTb.BindToProperty(binding); + Validation.SetErrorTemplate(DateTimeTb, null); } } } diff --git a/src/Libraries/CoreNodeModelsWpf/Controls/DynamoSlider.xaml b/src/Libraries/CoreNodeModelsWpf/Controls/DynamoSlider.xaml index d4b104af0e7..227fe34dedc 100644 --- a/src/Libraries/CoreNodeModelsWpf/Controls/DynamoSlider.xaml +++ b/src/Libraries/CoreNodeModelsWpf/Controls/DynamoSlider.xaml @@ -150,8 +150,7 @@ Background="{StaticResource MidGreyBrush}" BorderBrush="#4A4A4A" BorderThickness="1" - Foreground="#bcbcbc" - Text="{Binding MinText, Mode=OneWay, UpdateSourceTrigger=Explicit}" /> + Foreground="#bcbcbc" /> + Foreground="#bcbcbc" /> + Foreground="#bcbcbc" /> @@ -201,8 +198,7 @@ BorderThickness="1" TextWrapping="NoWrap" Foreground="{StaticResource PrimaryCharcoal100Brush}" - Opacity="1" - Text="{Binding ValueText, Mode=OneWay}"> + Opacity="1"> diff --git a/src/Libraries/CoreNodeModelsWpf/Controls/DynamoSlider.xaml.cs b/src/Libraries/CoreNodeModelsWpf/Controls/DynamoSlider.xaml.cs index b363143f9dc..b0365e87ad2 100644 --- a/src/Libraries/CoreNodeModelsWpf/Controls/DynamoSlider.xaml.cs +++ b/src/Libraries/CoreNodeModelsWpf/Controls/DynamoSlider.xaml.cs @@ -1,11 +1,14 @@ -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Input; -using System.Windows.Shapes; using Dynamo.Graph.Nodes; using Dynamo.Graph.Workspaces; +using Dynamo.Nodes; using Dynamo.UI; using Dynamo.ViewModels; +using System; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Shapes; namespace CoreNodeModelsWpf.Controls { @@ -31,6 +34,41 @@ public DynamoSlider(NodeModel model, IViewModelView nodeUI) } + /// + /// Installs the Text bindings for the value, min, max and step text boxes. + /// + /// + /// Optional factory, invoked once per text box. It must return a new rule instance on + /// every call - a shared instance would let one field's validation state leak into another. + /// Pass null to bind without validation. + /// + public void BindValidatedTextBoxes(Func ruleFactory = null) + { + BindField(ValTb, "ValueText", ruleFactory?.Invoke()); + BindField(MinTb, "MinText", ruleFactory?.Invoke()); + BindField(MaxTb, "MaxText", ruleFactory?.Invoke()); + BindField(StepTb, "StepText", ruleFactory?.Invoke()); + } + + private static void BindField(DynamoTextBox textBox, string propertyName, ValidationRule validationRule) + { + var binding = new Binding(propertyName) + { + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.Explicit, + NotifyOnValidationError = false + }; + + if (validationRule != null) + { + validationRule.ValidationStep = ValidationStep.RawProposedValue; + binding.ValidationRules.Add(validationRule); + } + + textBox.BindToProperty(binding); + Validation.SetErrorTemplate(textBox, null); + } + #region Event Handlers private void Slider_OnDragStarted(object sender, DragStartedEventArgs e) diff --git a/src/Libraries/CoreNodeModelsWpf/InputValidationRules.cs b/src/Libraries/CoreNodeModelsWpf/InputValidationRules.cs new file mode 100644 index 00000000000..aff81084a1f --- /dev/null +++ b/src/Libraries/CoreNodeModelsWpf/InputValidationRules.cs @@ -0,0 +1,71 @@ +using System.Globalization; +using System.Numerics; +using System.Windows.Controls; +using CoreNodeModels.Properties; + +namespace CoreNodeModelsWpf +{ + /// + /// Accepts invariant numeric doubles/longs. + /// + public class NumericValidationRule : ValidationRule + { + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + var text = value as string; + if (IsNumeric(text)) return ValidationResult.ValidResult; + + return new ValidationResult(false, Resources.NumberNodeInputMustBeNumeric); + } + + internal static bool IsNumeric(string value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + + return double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out _) || long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _); + } + } + + /// + /// Accepts Int64 integers only. Non-numeric and overflow both fail (Error bubble via DynamoTextBox). + /// + public class Integer64ValidationRule : ValidationRule + { + private const NumberStyles IntegerStyles = NumberStyles.Integer | NumberStyles.AllowThousands; + + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + var text = value as string; + if (string.IsNullOrWhiteSpace(text)) + { + return new ValidationResult(false, Resources.NumberNodeInputMustBeNumeric); + } + + if (long.TryParse(text, IntegerStyles, CultureInfo.InvariantCulture, out _)) + { + return ValidationResult.ValidResult; + } + + return BigInteger.TryParse(text, IntegerStyles, CultureInfo.InvariantCulture, out _) + ? new ValidationResult(false, Resources.IntegerSliderInfoMessage) + : new ValidationResult(false, Resources.IntegerSliderInputMustBeInteger); + } + } + + /// + /// Accepts PreferenceSettings.DefaultDateFormat only. + /// + public class DateTimeValidationRule : ValidationRule + { + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + var text = value as string; + if (CoreNodeModels.Input.DateTime.TryParseDateTime(text, out _)) + { + return ValidationResult.ValidResult; + } + + return new ValidationResult(false, Resources.DateTimeNodeInputInvalidFormat); + } + } +} diff --git a/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/DoubleInput.cs b/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/DoubleInput.cs index 1b371a80632..5a789cf8c1c 100644 --- a/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/DoubleInput.cs +++ b/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/DoubleInput.cs @@ -2,45 +2,13 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Data; -using System.Windows.Media; using CoreNodeModels.Input; -using CoreNodeModels.Properties; using Dynamo.Controls; using Dynamo.Nodes; using Dynamo.Wpf; namespace CoreNodeModelsWpf.Nodes -{ - internal class NumericValidationRule : ValidationRule - { - //if the string can be parsed to a common numeric type return true - internal bool validateInput(string value) - { - double doubleVal; - long longVal; - - if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out doubleVal) - || long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out longVal)) - { - return true; - } - return false; - } - - public override ValidationResult Validate(object value, CultureInfo cultureInfo) - { - - if (!validateInput(value as string)) - { - return new ValidationResult(false, Resources.NumberNodeInputMustBeNumeric); - } - else - { - return new ValidationResult(true, null); - } - } - } - +{ public class DoubleInputNodeViewCustomization : INodeViewCustomization { public void CustomizeView(DoubleInput nodeModel, NodeView nodeView) @@ -67,8 +35,9 @@ public void CustomizeView(DoubleInput nodeModel, NodeView nodeView) Source = nodeModel, UpdateSourceTrigger = UpdateSourceTrigger.Explicit }; + var numericalValidation = new NumericValidationRule(); - numericalValidation.ValidationStep = ValidationStep.ConvertedProposedValue; + numericalValidation.ValidationStep = ValidationStep.RawProposedValue; textToValueBinding.ValidationRules.Add(numericalValidation); tb.BindToProperty(textToValueBinding); Validation.SetErrorTemplate(tb, null); diff --git a/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/DoubleSlider.cs b/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/DoubleSlider.cs index 681939421a2..6bff84ffa3d 100644 --- a/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/DoubleSlider.cs +++ b/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/DoubleSlider.cs @@ -14,6 +14,8 @@ public void CustomizeView(DoubleSlider model, NodeView nodeView) DataContext = new SliderViewModel(model) }; + slider.BindValidatedTextBoxes(() => new NumericValidationRule()); + nodeView.inputGrid.Children.Add(slider); } diff --git a/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/IntegerSlider.cs b/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/IntegerSlider.cs index 778cce2563a..7b355fe8b99 100644 --- a/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/IntegerSlider.cs +++ b/src/Libraries/CoreNodeModelsWpf/NodeViewCustomizations/IntegerSlider.cs @@ -14,6 +14,8 @@ public void CustomizeView(IntegerSlider model, NodeView nodeView) DataContext = new SliderViewModel(model) }; + slider.BindValidatedTextBoxes(); + nodeView.inputGrid.Children.Add(slider); } @@ -29,6 +31,8 @@ public void CustomizeView(IntegerSlider64Bit model, NodeView nodeView) DataContext = new SliderViewModel(model) }; + slider.BindValidatedTextBoxes(() => new Integer64ValidationRule()); + nodeView.inputGrid.Children.Add(slider); } diff --git a/src/Libraries/CoreNodeModelsWpf/SliderViewModel.cs b/src/Libraries/CoreNodeModelsWpf/SliderViewModel.cs index ff1d6915139..1527f25df50 100644 --- a/src/Libraries/CoreNodeModelsWpf/SliderViewModel.cs +++ b/src/Libraries/CoreNodeModelsWpf/SliderViewModel.cs @@ -17,24 +17,31 @@ public class SliderViewModel : NotificationObject where T : IComparable { private SliderBase model; + // These text setters intentionally discard the value. Bindings are TwoWay so + // ValidateWithoutUpdate() can run; DynamoTextBox writes the model via + // UpdateModelValueCommand. Implementing these would double-commit on every edit. public string MaxText { - get { return SliderBase.ConvertNumberToString(model.Max); } + get => SliderBase.ConvertNumberToString(model.Max); + set => _ = value; } public string MinText { - get { return SliderBase.ConvertNumberToString(model.Min); } + get => SliderBase.ConvertNumberToString(model.Min); + set => _ = value; } public string StepText { - get { return SliderBase.ConvertNumberToString(model.Step); } + get => SliderBase.ConvertNumberToString(model.Step); + set => _ = value; } public string ValueText { - get { return SliderBase.ConvertNumberToString(model.Value); } + get => SliderBase.ConvertNumberToString(model.Value); + set => _ = value; } public T Max diff --git a/test/DynamoCoreWpf3Tests/InputValidationErrorBubbleTests.cs b/test/DynamoCoreWpf3Tests/InputValidationErrorBubbleTests.cs new file mode 100644 index 00000000000..97d82273242 --- /dev/null +++ b/test/DynamoCoreWpf3Tests/InputValidationErrorBubbleTests.cs @@ -0,0 +1,296 @@ +using CoreNodeModels.Input; +using CoreNodeModels.Properties; +using CoreNodeModelsWpf.Controls; +using Dynamo.Configuration; +using Dynamo.Controls; +using Dynamo.Graph.Nodes; +using Dynamo.Models; +using Dynamo.Nodes; +using Dynamo.Utilities; +using DynamoCoreWpfTests.Utility; +using NUnit.Framework; +using System.Globalization; +using System.Linq; +using System.Windows.Controls; +using System.Windows.Data; +using DateTimeNode = CoreNodeModels.Input.DateTime; + +namespace DynamoCoreWpfTests +{ + public class InputValidationErrorBubbleTests : DynamoTestUIBase + { + protected override void GetLibrariesToPreload(System.Collections.Generic.List libraries) + { + libraries.Add("VMDataBridge.dll"); + libraries.Add("DSCoreNodes.dll"); + base.GetLibrariesToPreload(libraries); + } + + private static DynamoTextBox ValueBox(NodeView nodeView) + { + // Number / DateTime: single box. Sliders: ValTb. + var named = nodeView.ChildrenOfType() + .FirstOrDefault(tb => tb.Name == "ValTb" || tb.Name == "DateTimeTb"); + return named ?? nodeView.inputGrid.ChildrenOfType().First(); + } + + private static int ErrorCount(NodeModel node) => node.NodeInfos.Count(i => i.State == ElementState.Error); + + private NodeView AddAndGetView(NodeModel node) + { + Model.AddNodeToCurrentWorkspace(node, true); + DispatcherUtil.DoEventsLoop(() => View.NodeViewsInFirstWorkspace().Any(nv => nv.ViewModel.NodeLogic.GUID == node.GUID), timeoutSeconds: 5); + return NodeViewWithGuid(node.GUID.ToString()); + } + + private void CommitText(DynamoTextBox box, string text) + { + box.Text = text; // triggers DynamoTextBox.UpdateDataSource(recordForUndo: true) + DispatcherUtil.DoEvents(); + } + + #region Number (DoubleInput) + + [Test] + public void WhenNumberGetsInvalidInputThenKeepsValueShowsErrorKeepsTextAndSkipsUndo() + { + var node = new DoubleInput(); + var view = AddAndGetView(node); + var box = ValueBox(view); + + CommitText(box, "1"); + CommitText(box, "2"); + CommitText(box, "K"); + + Assert.AreEqual("2", node.Value); + Assert.AreEqual("K", box.Text); + Assert.IsTrue(node.IsInErrorState); + Assert.AreEqual(1, ErrorCount(node)); + Assert.AreEqual(Resources.NumberNodeInputMustBeNumeric, + node.NodeInfos.Single(i => i.State == ElementState.Error).Message); + + Model.CurrentWorkspace.Undo(); + DispatcherUtil.DoEvents(); + + // Invalid "K" was not recorded — undo reverses 1→2, not 2→K. + Assert.AreEqual("1", node.Value); + Assert.IsFalse(node.IsInErrorState); + Assert.AreEqual(0, ErrorCount(node)); + Assert.AreEqual("1.000", box.Text); + } + + [Test] + public void WhenNumberGetsValidInputAfterErrorThenClearsBubbleAndUpdatesValue() + { + var node = new DoubleInput(); + var view = AddAndGetView(node); + var box = ValueBox(view); + + CommitText(box, "5"); + CommitText(box, "K"); + Assert.IsTrue(node.IsInErrorState); + + CommitText(box, "7"); + + Assert.AreEqual("7", node.Value); + Assert.AreEqual("7.000", box.Text); + Assert.IsFalse(node.IsInErrorState); + Assert.AreEqual(0, ErrorCount(node)); + } + + [Test] + public void WhenNumberSwapsInvalidInputsThenShowsOnlyLatestError() + { + var node = new DoubleInput(); + var view = AddAndGetView(node); + var box = ValueBox(view); + + CommitText(box, "1"); + CommitText(box, "K"); + CommitText(box, "m"); + + Assert.AreEqual("1", node.Value); + Assert.AreEqual("m", box.Text); + Assert.AreEqual(1, ErrorCount(node)); + Assert.AreEqual(Resources.NumberNodeInputMustBeNumeric, + node.NodeInfos.Single(i => i.State == ElementState.Error).Message); + } + + [Test] + public void WhenNumberUndoesAfterErrorOnSameValueThenClearsBubble() + { + // Repro: Value setter early-returns when restored string equals current; + // DeserializeCore must clear Infos unconditionally. + var node = new DoubleInput(); + AddAndGetView(node); + + Model.ExecuteCommand(new DynamoModel.UpdateModelValueCommand( + Model.CurrentWorkspace.Guid, node.GUID, nameof(DoubleInput.Value), "5")); + Model.ExecuteCommand(new DynamoModel.UpdateModelValueCommand( + Model.CurrentWorkspace.Guid, node.GUID, nameof(DoubleInput.Value), "5")); + + var view = NodeViewWithGuid(node.GUID.ToString()); + CommitText(ValueBox(view), "K"); + Assert.IsTrue(node.IsInErrorState); + + Model.CurrentWorkspace.Undo(); + DispatcherUtil.DoEvents(); + + Assert.AreEqual("5", node.Value); + Assert.IsFalse(node.IsInErrorState); + Assert.AreEqual(0, ErrorCount(node)); + } + + #endregion + + #region Integer Slider 64-bit + + [Test] + public void WhenIntegerSlider64GetsInvalidInputThenKeepsValueShowsErrorAndSkipsUndo() + { + var node = new IntegerSlider64Bit(); + var view = AddAndGetView(node); + var box = ValueBox(view); + + CommitText(box, "1"); + CommitText(box, "2"); + CommitText(box, "K"); + + Assert.AreEqual(2L, node.Value); + Assert.AreEqual("K", box.Text); + Assert.IsTrue(node.IsInErrorState); + Assert.AreEqual(1, ErrorCount(node)); + + Model.CurrentWorkspace.Undo(); + DispatcherUtil.DoEvents(); + + Assert.AreEqual(1L, node.Value); + Assert.IsFalse(node.IsInErrorState); + Assert.AreEqual("1", box.Text); + } + + [Test] + public void WhenIntegerSlider64GetsThousandsGroupedInputThenAcceptsValue() + { + var node = new IntegerSlider64Bit { Max = 10000 }; + var view = AddAndGetView(node); + var box = ValueBox(view); + + CommitText(box, "2,500"); + + Assert.AreEqual(2500L, node.Value); + Assert.IsFalse(node.IsInErrorState); + Assert.AreEqual(0, ErrorCount(node)); + } + + [Test] + public void WhenIntegerSlider64OverflowsThenShowsRangeErrorNotStacked() + { + var node = new IntegerSlider64Bit(); + var view = AddAndGetView(node); + var box = ValueBox(view); + + CommitText(box, "1"); + CommitText(box, "m"); + Assert.AreEqual(Resources.IntegerSliderInputMustBeInteger, + node.NodeInfos.Single(i => i.State == ElementState.Error).Message); + + CommitText(box, "9223372036854775808"); // long.MaxValue + 1 + + Assert.AreEqual(1L, node.Value); + Assert.AreEqual(1, ErrorCount(node)); + Assert.AreEqual(Resources.IntegerSliderInfoMessage, + node.NodeInfos.Single(i => i.State == ElementState.Error).Message); + } + + #endregion + + #region Double Slider + + [Test] + public void WhenDoubleSliderGetsInvalidInputThenKeepsValueShowsErrorAndClearsOnValid() + { + var node = new CoreNodeModels.Input.DoubleSlider(); + var view = AddAndGetView(node); + var box = ValueBox(view); + + CommitText(box, "3.5"); + CommitText(box, "abc"); + + Assert.AreEqual(3.5, node.Value, 1e-9); + Assert.AreEqual("abc", box.Text); + Assert.IsTrue(node.IsInErrorState); + + CommitText(box, "4.25"); + + Assert.AreEqual(4.25, node.Value, 1e-9); + Assert.IsFalse(node.IsInErrorState); + Assert.AreEqual(0, ErrorCount(node)); + } + + #endregion + + #region DateTime + + [Test] + public void WhenDateTimeGetsInvalidInputThenKeepsValueShowsErrorAndClearsOnUndo() + { + var node = new DateTimeNode + { + // DefaultDateFormat carries only minutes, so we need a value that round-trips + // exactly through serialize/undo. UtcNow would lose seconds and ticks. + Value = new System.DateTime(2015, 5, 30, 5, 30, 0, System.DateTimeKind.Utc) + }; + var original = node.Value; + var view = AddAndGetView(node); + var box = ValueBox(view); + + var valid = new System.DateTime(2020, 12, 8, 12, 0, 0, System.DateTimeKind.Utc); + CommitText(box, valid.ToString(PreferenceSettings.DefaultDateFormat, CultureInfo.InvariantCulture)); + Assert.AreEqual(valid, node.Value); + + CommitText(box, "not-a-date"); + + Assert.AreEqual(valid, node.Value); + Assert.AreEqual("not-a-date", box.Text); + Assert.IsTrue(node.IsInErrorState); + Assert.AreEqual(Resources.DateTimeNodeInputInvalidFormat, + node.NodeInfos.Single(i => i.State == ElementState.Error).Message); + + Model.CurrentWorkspace.Undo(); + DispatcherUtil.DoEvents(); + + Assert.AreEqual(original, node.Value); + Assert.IsFalse(node.IsInErrorState); + Assert.AreEqual(0, ErrorCount(node)); + } + + #endregion + + #region Legacy IntegerSlider (32-bit) binding regression + + [Test] + public void WhenIntegerSliderCustomizedThenTextBoxesAreBoundAndPopulated() + { + var node = new CoreNodeModels.Input.IntegerSlider64Bit { Value = 41, Min = 0, Max = 100, Step = 1 }; + var view = AddAndGetView(node); + var slider = view.ChildrenOfType().First(); + + foreach (var name in new[] { "ValTb", "MinTb", "MaxTb", "StepTb" }) + { + var tb = (DynamoTextBox)slider.FindName(name); + Assert.IsNotNull(tb, $"{name} missing"); + Assert.IsNotNull( + BindingOperations.GetBindingExpression(tb, TextBox.TextProperty), + $"{name} has no Text binding"); + } + + Assert.AreEqual("41", ((DynamoTextBox)slider.FindName("ValTb")).Text); + Assert.AreEqual("0", ((DynamoTextBox)slider.FindName("MinTb")).Text); + Assert.AreEqual("100", ((DynamoTextBox)slider.FindName("MaxTb")).Text); + Assert.AreEqual("1", ((DynamoTextBox)slider.FindName("StepTb")).Text); + } + + #endregion + } +} diff --git a/test/DynamoCoreWpf3Tests/NodeViewCustomizationTests.cs b/test/DynamoCoreWpf3Tests/NodeViewCustomizationTests.cs index bc81292876c..b7272f38286 100644 --- a/test/DynamoCoreWpf3Tests/NodeViewCustomizationTests.cs +++ b/test/DynamoCoreWpf3Tests/NodeViewCustomizationTests.cs @@ -1,13 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Windows; -using System.Windows.Controls; using CoreNodeModels.Input; +using CoreNodeModelsWpf; using CoreNodeModelsWpf.Controls; -using CoreNodeModelsWpf.Nodes; +using Dynamo.Configuration; using Dynamo.Controls; using Dynamo.Graph.Nodes; using Dynamo.Graph.Nodes.CustomNodes; @@ -17,6 +11,13 @@ using Dynamo.Utilities; using DynamoCoreWpfTests.Utility; using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; namespace DynamoCoreWpfTests { @@ -503,6 +504,66 @@ public void INodeViewCustomizationCheckUsingReflectionIsCorrect() //this assembly contains some builtin nodeviewcustomizations and this methd should return true. Assert.IsFalse(NodeModelAssemblyLoader.ContainsNodeViewCustomizationType(dyncoreAssem)); } + + + + + + [Test] + [Category("UnitTests")] + public static void WhenInteger64InputIsNonIntegerThenValidationFails() + { + var rule = new Integer64ValidationRule(); + Assert.IsFalse(rule.Validate("m", CultureInfo.InvariantCulture).IsValid); + Assert.IsFalse(rule.Validate("1.5", CultureInfo.InvariantCulture).IsValid); + Assert.IsFalse(rule.Validate("", CultureInfo.InvariantCulture).IsValid); + Assert.IsFalse(rule.Validate(" ", CultureInfo.InvariantCulture).IsValid); + } + + [Test] + [Category("UnitTests")] + public static void WhenInteger64InputIsValidOrGroupedThenValidationPasses() + { + var rule = new Integer64ValidationRule(); + Assert.IsTrue(rule.Validate("0", CultureInfo.InvariantCulture).IsValid); + Assert.IsTrue(rule.Validate("2500", CultureInfo.InvariantCulture).IsValid); + Assert.IsTrue(rule.Validate("2,500", CultureInfo.InvariantCulture).IsValid); + Assert.IsTrue(rule.Validate("-2,500", CultureInfo.InvariantCulture).IsValid); + } + + [Test] + [Category("UnitTests")] + public static void WhenInteger64InputOverflowsThenValidationFailsWithRangeMessage() + { + var rule = new Integer64ValidationRule(); + var result = rule.Validate("9223372036854775808", CultureInfo.InvariantCulture); // long.MaxValue + 1 + Assert.IsFalse(result.IsValid); + Assert.AreEqual(CoreNodeModels.Properties.Resources.IntegerSliderInfoMessage, result.ErrorContent); + } + + [Test] + [Category("UnitTests")] + public static void WhenDateTimeInputMatchesDefaultFormatThenValidationPasses() + { + var rule = new DateTimeValidationRule(); + var text = new System.DateTime(2000, 1, 1, 12, 0, 0) + .ToString(PreferenceSettings.DefaultDateFormat, CultureInfo.InvariantCulture); + Assert.IsTrue(rule.Validate(text, CultureInfo.InvariantCulture).IsValid); + Assert.IsTrue(CoreNodeModels.Input.DateTime.TryParseDateTime(text, out _)); + } + + [Test] + [Category("UnitTests")] + public static void WhenDateTimeInputIsInvalidThenValidationFails() + { + var rule = new DateTimeValidationRule(); + var result = rule.Validate("not-a-date", CultureInfo.InvariantCulture); + Assert.IsFalse(result.IsValid); + Assert.AreEqual( + CoreNodeModels.Properties.Resources.DateTimeNodeInputInvalidFormat, + result.ErrorContent); + Assert.IsFalse(CoreNodeModels.Input.DateTime.TryParseDateTime("not-a-date", out _)); + } } }