using AtomUI.Controls; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; using Shouldly; using Xunit; using AvaloniaWindow = Avalonia.Controls.Window; namespace AtomUI.Desktop.Controls.Tests.Form; public class FormBehaviorTests { static FormBehaviorTests() { AvaloniaTestApp.EnsureInitialized(); } [Fact] public void FormItemDecorator_ExtraProperty_Is_Registered_On_Decorator() { FormItemDecorator.ExtraProperty.OwnerType.ShouldBe(typeof(FormItemDecorator)); } [Fact] public void FormTheme_Binds_Validating_Feedback_To_Validating_Property() { var source = ReadRepoFile("src/AtomUI.Desktop.Controls/Form/Themes/FormTheme.axaml"); source.ShouldContain("ValidatingFeedback=\"{Binding ValidatingFeedback}\""); source.ShouldNotContain("ValidatingFeedback=\"{Binding WarningFeedback}\""); } [Fact] public void Form_Default_ValidateTrigger_Is_OnChanged() { var form = new global::AtomUI.Desktop.Controls.Form(); form.ValidateTrigger.ShouldBe(FormValidateTrigger.OnChanged); } [Fact] public async Task FormActionsItem_Without_Validators_Does_Not_Require_FormItemAware_Content() { var formItem = new FormActionsItem { Content = new Avalonia.Controls.Button() }; await formItem.ValidateValueAsync(CancellationToken.None); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Success); formItem.ValidateResult.ShouldBe(FormValidateResult.Success); } [Fact] public void FormValidatorProvider_Is_Compatible_With_FormItem_Validators() { var provider = new FormValidatorProvider(); provider.ShouldBeAssignableTo>(); var validators = (IList)provider; validators.Add(new FormStringNotEmptyValidator()); var formItem = new FormItem { Validators = validators }; formItem.Validators.ShouldBeSameAs(provider); formItem.Validators.Count.ShouldBe(1); } [Fact] public void FormItem_Validates_On_Content_Change_When_Default_Trigger_Is_OnChanged() { var content = new FeedbackAwareFormControl(); var formItem = new FormItem { OwnerForm = new global::AtomUI.Desktop.Controls.Form(), LabelText = "Name", FieldName = "name", Content = content, Validators = [new StaticValidator(FormValidateResult.Error)] }; content.RaiseValueChanged(); Dispatcher.UIThread.RunJobs(); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Error); formItem.ValidateResult.ShouldBe(FormValidateResult.Error); } [Fact] public async Task FormItem_Default_OnChanged_Revalidates_After_Submit_Error() { var content = new FeedbackAwareFormControl(); var formItem = new FormItem { OwnerForm = new global::AtomUI.Desktop.Controls.Form(), LabelText = "Name", FieldName = "name", Content = content, Validators = [new FormStringNotEmptyValidator { Message = "required" }] }; await formItem.ValidateValueAsync(CancellationToken.None); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Error); DataValidationErrors.GetHasErrors(content).ShouldBeTrue(); content.Value = "AtomUI"; content.RaiseValueChanged(); RunLayoutJobs(); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Success); formItem.ValidateResult.ShouldBe(FormValidateResult.Success); DataValidationErrors.GetHasErrors(content).ShouldBeFalse(); } [Fact] public void FormItem_Explicit_OnSubmit_Does_Not_Validate_On_Content_Change() { var content = new FeedbackAwareFormControl(); var formItem = new FormItem { OwnerForm = new global::AtomUI.Desktop.Controls.Form(), LabelText = "Name", FieldName = "name", Content = content, ValidateTrigger = FormValidateTrigger.OnSubmit, Validators = [new StaticValidator(FormValidateResult.Error)] }; content.RaiseValueChanged(); RunLayoutJobs(); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Default); formItem.ValidateResult.ShouldBe(FormValidateResult.Success); } [Fact] public void FormItemDecorator_Replays_Feedback_To_New_Child_And_Releases_Old_Child() { var oldChild = new FeedbackAwareFormControl(); var newChild = new FeedbackAwareFormControl(); var feedback = new FormValidateFeedback(); var decorator = new FormItemDecorator { Child = oldChild }; ((IFormItemFeedbackAware)decorator).SetFeedbackControl(feedback); oldChild.Feedback.ShouldBeSameAs(feedback); decorator.Child = newChild; oldChild.Feedback.ShouldBeNull(); newChild.Feedback.ShouldBeSameAs(feedback); } [Fact] public void FormItem_Releases_Feedback_From_Old_Content_When_Content_Changes() { var oldContent = new FeedbackAwareFormControl(); var newContent = new FeedbackAwareFormControl(); var formItem = new FormItem { OwnerForm = new global::AtomUI.Desktop.Controls.Form(), IsValidateFeedbackEnabled = true, FeedbackTemplate = new FuncDataTemplate((_, _) => new FormValidateFeedback()), Content = oldContent }; oldContent.Feedback.ShouldNotBeNull(); formItem.Content = newContent; oldContent.Feedback.ShouldBeNull(); newContent.Feedback.ShouldNotBeNull(); newContent.Feedback.ShouldBeSameAs(oldContent.LastReleasedFeedback); } [Fact] public async Task FormItem_Ignores_Result_From_Canceled_Validation() { var firstResult = new TaskCompletionSource(); var secondResult = new TaskCompletionSource(); var validator = new QueuedValidator(firstResult.Task, secondResult.Task); var formItem = new FormItem { FieldName = "name", LabelText = "Name", Content = new FeedbackAwareFormControl(), Validators = [validator] }; using var firstCancellation = new CancellationTokenSource(); var firstValidation = formItem.ValidateValueAsync(firstCancellation.Token); firstCancellation.Cancel(); var secondValidation = formItem.ValidateValueAsync(CancellationToken.None); secondResult.SetResult(FormValidateResult.Success); await secondValidation.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Success); firstResult.SetResult(FormValidateResult.Error); await firstValidation.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Success); } [Fact] public async Task FormItem_Reset_Cancels_Pending_Validation_Result() { var result = new TaskCompletionSource(); var content = new FeedbackAwareFormControl(); var formItem = new FormItem { FieldName = "name", LabelText = "Name", Content = content, Validators = [new QueuedValidator(result.Task)] }; var validation = formItem.ValidateValueAsync(CancellationToken.None); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Validating); formItem.ResetItemValue(); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Default); content.LastValidateStatus.ShouldBe(FormValidateStatus.Default); result.SetResult(FormValidateResult.Error); await validation.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Default); content.LastValidateStatus.ShouldBe(FormValidateStatus.Default); } [Fact] public async Task FormItem_Parallel_Validation_Allows_Validators_To_Return_Same_Task() { var result = Task.FromResult(FormValidateResult.Success); var formItem = new FormItem { FieldName = "name", LabelText = "Name", Content = new FeedbackAwareFormControl(), ValidateStrategy = FormValidateStrategy.Parallel, Validators = [ new SameTaskValidator(result), new SameTaskValidator(result) ] }; await formItem.ValidateValueAsync(CancellationToken.None); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Success); } [Fact] public async Task FormItem_StopWhenFirstFailed_Keeps_Warnings_Before_Error() { var formItem = new FormItem { FieldName = "name", LabelText = "Name", Content = new FeedbackAwareFormControl(), ValidateStrategy = FormValidateStrategy.StopWhenFirstFailed, Validators = [ new StaticValidator(FormValidateResult.Warning, "warning"), new StaticValidator(FormValidateResult.Error, "error"), new StaticValidator(FormValidateResult.Error, "skipped") ] }; await formItem.ValidateValueAsync(CancellationToken.None); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Error); formItem.ValidateWarningMessages.ShouldBe(["warning"]); formItem.ValidateErrorMessages.ShouldBe(["error"]); } [Fact] public async Task FormItem_Validator_Error_Is_Written_To_DataValidationErrors() { var content = new FeedbackAwareFormControl(); var formItem = new FormItem { FieldName = "name", LabelText = "Name", Content = content, Validators = [new StaticValidator(FormValidateResult.Error, "required")] }; await formItem.ValidateValueAsync(CancellationToken.None); DataValidationErrors.GetHasErrors(content).ShouldBeTrue(); var errors = DataValidationErrors.GetErrors(content)!.Select(error => error.ToString()).ToList(); errors.Any(error => error is not null && error.Contains("required")).ShouldBeTrue(); } [Fact] public async Task FormItem_Reset_Clears_Form_Owned_Errors_But_Preserves_External_DataValidationErrors() { var content = new FeedbackAwareFormControl(); var externalError = new InvalidOperationException("external"); var formItem = new FormItem { FieldName = "name", LabelText = "Name", Content = content, Validators = [new StaticValidator(FormValidateResult.Error, "required")] }; DataValidationErrors.SetError(content, externalError); await formItem.ValidateValueAsync(CancellationToken.None); DataValidationErrors.GetErrors(content)!.Count().ShouldBe(2); formItem.ResetItemValue(); var remainingErrors = DataValidationErrors.GetErrors(content)!.ToList(); remainingErrors.ShouldBe([externalError]); DataValidationErrors.GetHasErrors(content).ShouldBeTrue(); } [Fact] public async Task FormItem_External_DataValidationError_Takes_Priority_Over_Form_Warning() { var content = new FeedbackAwareFormControl(); var externalError = new InvalidOperationException("external"); var formItem = new FormItem { FieldName = "name", LabelText = "Name", Content = content, Validators = [new StaticValidator(FormValidateResult.Warning, "warning")] }; FormValidateStatus? changedStatus = null; formItem.ValidateChanged += (_, args) => changedStatus = args.Status; DataValidationErrors.SetError(content, externalError); await formItem.ValidateValueAsync(CancellationToken.None); formItem.ValidateStatus.ShouldBe(FormValidateStatus.Error); formItem.ValidateResult.ShouldBe(FormValidateResult.Error); changedStatus.ShouldBe(FormValidateStatus.Error); formItem.ValidateWarningMessages.ShouldBe(["warning"]); formItem.ValidateErrorMessages.ShouldNotBeNull(); formItem.ValidateErrorMessages!.ShouldContain(message => message.Contains("external")); } [Fact] public async Task SubmitButton_WatchValidateResult_Disables_Until_Form_Is_Valid() { var form = new global::AtomUI.Desktop.Controls.Form(); var formItem = new FormItem { LabelText = "Name", FieldName = "name", Content = new FeedbackAwareFormControl(), Validators = [new StaticValidator(FormValidateResult.Success)] }; var submitButton = new SubmitButton { IsWatchValidateResult = true }; form.Items.Add(formItem); form.Items.Add(new FormActionsItem { Content = submitButton }); var window = CreateWindow(form); try { window.Show(); Dispatcher.UIThread.RunJobs(); submitButton.IsEnabled.ShouldBeFalse(); await form.ValidateAsync(CancellationToken.None); Dispatcher.UIThread.RunJobs(); submitButton.IsEnabled.ShouldBeTrue(); } finally { window.Close(); } } [Fact] public void FormItem_Uses_Configured_Columns_For_ExtraExtraExtraLarge_Breakpoint() { var form = new global::AtomUI.Desktop.Controls.Form { FormLayout = FormLayout.Horizontal, LabelColInfo = new MediaBreakGridLength(new GridLength(120)), WrapperColInfo = new MediaBreakGridLength(GridLength.Star), HorizontalAlignment = HorizontalAlignment.Stretch }; var formItem = new FormItem { LabelText = "Name", HorizontalAlignment = HorizontalAlignment.Stretch, Content = new LineEdit { HorizontalAlignment = HorizontalAlignment.Stretch } }; form.Items.Add(formItem); var mediaHost = new MediaBreakAwarePanel(MediaBreakPoint.ExtraExtraExtraLarge) { Width = 728, Height = 80 }; mediaHost.Children.Add(form); var window = CreateWindow(mediaHost); try { window.Show(); RunLayoutJobs(); mediaHost.RaiseMediaBreakPointChanged(); RunLayoutJobs(); var bodyLayout = formItem.GetVisualDescendants() .OfType() .Single(item => item.Name == "PART_BodyLayout"); bodyLayout.ColumnDefinitions[0].Width.ShouldBe(new GridLength(120)); bodyLayout.ColumnDefinitions[1].Width.ShouldBe(GridLength.Star); } finally { window.Close(); } } [Fact] public void FormItem_LabelWrap_Keeps_Label_Text_Inside_Label_Column() { var form = new global::AtomUI.Desktop.Controls.Form { FormLayout = FormLayout.Horizontal, LabelColInfo = new MediaBreakGridLength(new GridLength(120)), WrapperColInfo = new MediaBreakGridLength(GridLength.Star), LabelAlign = FormLabelAlign.Left, LabelWrapping = TextWrapping.Wrap, IsShowColon = false, Width = 600, HorizontalAlignment = HorizontalAlignment.Left }; var formItem = new FormItem { LabelText = "A super long label text", FieldName = "password", IsRequired = true, Content = new LineEdit { HorizontalAlignment = HorizontalAlignment.Stretch } }; form.Items.Add(formItem); var window = CreateWindow(form); try { window.Width = 720; window.Height = 240; window.Show(); RunLayoutJobs(); var bodyLayout = FindVisualByName(formItem, "PART_BodyLayout"); var labelLayout = FindVisualByName(formItem, "PART_LabelLayout"); var labelContent = FindVisualByName(formItem, "PART_LabelContentLayout"); var label = FindVisualByName(formItem, "PART_Label"); var contentLayout = FindVisualByName(formItem, "PART_ContentLayout"); var labelLayoutPos = labelLayout.TranslatePoint(default, bodyLayout).ShouldNotBeNull(); var labelPos = label.TranslatePoint(default, bodyLayout).ShouldNotBeNull(); var contentPos = contentLayout.TranslatePoint(default, bodyLayout).ShouldNotBeNull(); labelContent.HorizontalAlignment.ShouldBe(HorizontalAlignment.Left); label.Bounds.Height.ShouldBeGreaterThan(24); labelPos.X.ShouldBeGreaterThanOrEqualTo(labelLayoutPos.X); (labelPos.X + label.Bounds.Width).ShouldBeLessThanOrEqualTo( labelLayoutPos.X + labelLayout.Bounds.Width + 0.5); contentPos.X.ShouldBeGreaterThanOrEqualTo( labelLayoutPos.X + labelLayout.Bounds.Width - 0.5); } finally { window.Close(); } } [Fact] public void FormItem_Default_LabelAlign_Keeps_Label_Content_Right_Aligned() { var form = new global::AtomUI.Desktop.Controls.Form { FormLayout = FormLayout.Horizontal, LabelColInfo = new MediaBreakGridLength(new GridLength(120)), WrapperColInfo = new MediaBreakGridLength(GridLength.Star), Width = 600, HorizontalAlignment = HorizontalAlignment.Left }; var formItem = new FormItem { LabelText = "Name", FieldName = "name", Content = new LineEdit { HorizontalAlignment = HorizontalAlignment.Stretch } }; form.Items.Add(formItem); var window = CreateWindow(form); try { window.Width = 720; window.Height = 200; window.Show(); RunLayoutJobs(); var labelLayout = FindVisualByName(formItem, "PART_LabelLayout"); var labelContent = FindVisualByName(formItem, "PART_LabelContentLayout"); var labelContentRight = labelContent.TranslatePoint( new Point(labelContent.Bounds.Width, 0), labelLayout).ShouldNotBeNull(); labelContent.HorizontalAlignment.ShouldBe(HorizontalAlignment.Right); labelContentRight.X.ShouldBe(labelLayout.Bounds.Width, 0.5); } finally { window.Close(); } } private static string ReadRepoFile(string relativePath) { return File.ReadAllText(Path.Combine(GetRepositoryRoot(), relativePath)); } private static string GetRepositoryRoot() { var directory = AppContext.BaseDirectory; while (directory != null) { if (File.Exists(Path.Combine(directory, "AtomUI.slnx"))) { return directory; } directory = Directory.GetParent(directory)?.FullName; } throw new DirectoryNotFoundException("Unable to locate repository root."); } private static AvaloniaWindow CreateWindow(Control content) { return new AvaloniaWindow { Width = 420, Height = 320, Content = content }; } private static void RunLayoutJobs() { Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs(); } private static T FindVisualByName(Control root, string name) where T : Control { return root.GetVisualDescendants() .OfType() .Single(item => item.Name == name); } private sealed class FeedbackAwareFormControl : Control, IFormItemAware, IFormItemFeedbackAware { public FormValidateFeedback? Feedback { get; private set; } public FormValidateFeedback? LastReleasedFeedback { get; private set; } public FormValidateStatus? LastValidateStatus { get; private set; } public object? Value { get; set; } private EventHandler? _valueChanged; public event EventHandler? ValueChanged { add => _valueChanged += value; remove => _valueChanged -= value; } public void SetFormValue(object? value) { Value = value; } public object? GetFormValue() => Value; public void ClearFormValue() { Value = null; } public void NotifyValidateStatus(FormValidateStatus status) { LastValidateStatus = status; } public void SetFeedbackControl(FormValidateFeedback? value) { if (value is null) { LastReleasedFeedback = Feedback; } Feedback = value; } public void RaiseValueChanged() { _valueChanged?.Invoke(this, EventArgs.Empty); } } private sealed class MediaBreakAwarePanel(MediaBreakPoint mediaBreakPoint) : Panel, IMediaBreakAwareControl { public MediaBreakPoint MediaBreakPoint { get; } = mediaBreakPoint; public event EventHandler? MediaBreakPointChanged; public void RaiseMediaBreakPointChanged() { MediaBreakPointChanged?.Invoke(this, new MediaBreakPointChangedEventArgs(MediaBreakPoint)); } } private sealed class StaticValidator : IFormValidator { private readonly FormValidateResult _result; public StaticValidator(FormValidateResult result, string? message = "message") { _result = result; Message = message; } public string? Message { get; } public bool WarningOnly => _result == FormValidateResult.Warning; public Task ValidateAsync(string fieldName, object? value, CancellationToken cancellationToken) { return Task.FromResult(_result); } } private sealed class QueuedValidator : IFormValidator { private readonly Queue> _results; public QueuedValidator(params Task[] results) { _results = new Queue>(results); } public string? Message => "message"; public bool WarningOnly => false; public Task ValidateAsync(string fieldName, object? value, CancellationToken cancellationToken) { return _results.Dequeue(); } } private sealed class SameTaskValidator : IFormValidator { private readonly Task _result; public SameTaskValidator(Task result) { _result = result; } public string? Message => "message"; public bool WarningOnly => false; public Task ValidateAsync(string fieldName, object? value, CancellationToken cancellationToken) { return _result; } } }