using Microsoft.Playwright; namespace Enciphered.Blazor.UIComponents.Tests; /// /// Tests for htmx-powered server-side validation on the Forms Demo page. /// Covers: inline field validation on blur, full-form submission validation, /// success message display, error styling, and form reset clearing errors. /// [TestFixture] public class ValidationTests : PlaywrightTestBase { // ── Helpers ────────────────────────────────────────────────────────────── private async Task GoToFormsAsync() { await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 }); } private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']"); private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']"); private ILocator FieldError(string fieldName) => Page.Locator($"[data-field-error='{fieldName}']"); /// /// Focus an input, clear it, then blur to trigger htmx validation. /// private async Task BlurEmptyField(string testId) { var input = Input(testId); await input.ClickAsync(); await input.FillAsync(""); await input.BlurAsync(); // Wait for htmx round-trip await Page.WaitForTimeoutAsync(500); } /// /// Fill a field and blur it. /// private async Task FillAndBlur(string testId, string value) { var input = Input(testId); await input.ClickAsync(); await input.FillAsync(value); await input.BlurAsync(); await Page.WaitForTimeoutAsync(500); } /// /// Fill all required fields with valid data. /// private async Task FillAllValidFields() { await Input("input-name").FillAsync("Alice Johnson"); await Input("input-email").FillAsync("alice@example.com"); await Input("input-password").FillAsync("secure123"); await Input("input-confirmation").FillAsync("CONFIRM"); } // ════════════════════════════════════════════════════════════════════════ // Inline field validation — blur triggers // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Name_Blank_Shows_Required_Error_On_Blur() { await GoToFormsAsync(); await BlurEmptyField("input-name"); var error = FieldError("name"); await Expect(error).ToBeVisibleAsync(); await Expect(error).ToHaveTextAsync("Name is required."); } [Test] public async Task Name_Too_Short_Shows_Length_Error_On_Blur() { await GoToFormsAsync(); await FillAndBlur("input-name", "A"); var error = FieldError("name"); await Expect(error).ToBeVisibleAsync(); await Expect(error).ToHaveTextAsync("Name must be at least 2 characters."); } [Test] public async Task Name_Valid_Clears_Error_On_Blur() { await GoToFormsAsync(); // Trigger an error first await BlurEmptyField("input-name"); await Expect(FieldError("name")).ToBeVisibleAsync(); // Now fix it await FillAndBlur("input-name", "Alice"); var error = FieldError("name"); await Expect(error).ToBeHiddenAsync(); } [Test] public async Task Email_Blank_Shows_Required_Error_On_Blur() { await GoToFormsAsync(); await BlurEmptyField("input-email"); var error = FieldError("email"); await Expect(error).ToBeVisibleAsync(); await Expect(error).ToHaveTextAsync("Email is required."); } [Test] public async Task Email_Invalid_Shows_Format_Error_On_Blur() { await GoToFormsAsync(); await FillAndBlur("input-email", "notanemail"); var error = FieldError("email"); await Expect(error).ToBeVisibleAsync(); await Expect(error).ToHaveTextAsync("Please enter a valid email address."); } [Test] public async Task Email_Valid_Clears_Error_On_Blur() { await GoToFormsAsync(); await BlurEmptyField("input-email"); await Expect(FieldError("email")).ToBeVisibleAsync(); await FillAndBlur("input-email", "alice@example.com"); await Expect(FieldError("email")).ToBeHiddenAsync(); } [Test] public async Task Password_Blank_Shows_Required_Error_On_Blur() { await GoToFormsAsync(); await BlurEmptyField("input-password"); var error = FieldError("password"); await Expect(error).ToBeVisibleAsync(); await Expect(error).ToHaveTextAsync("Password is required."); } [Test] public async Task Password_Too_Short_Shows_Length_Error_On_Blur() { await GoToFormsAsync(); await FillAndBlur("input-password", "abc"); var error = FieldError("password"); await Expect(error).ToBeVisibleAsync(); await Expect(error).ToHaveTextAsync("Password must be at least 6 characters."); } [Test] public async Task Password_Valid_Clears_Error_On_Blur() { await GoToFormsAsync(); await BlurEmptyField("input-password"); await Expect(FieldError("password")).ToBeVisibleAsync(); await FillAndBlur("input-password", "secure123"); await Expect(FieldError("password")).ToBeHiddenAsync(); } // ════════════════════════════════════════════════════════════════════════ // Input gets destructive border styling on error // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Input_Gets_Destructive_Border_On_Error() { await GoToFormsAsync(); await BlurEmptyField("input-name"); var cls = await Input("input-name").GetAttributeAsync("class"); Assert.That(cls, Does.Contain("border-destructive"), "Input should have destructive border on error"); } [Test] public async Task Input_Loses_Destructive_Border_When_Valid() { await GoToFormsAsync(); await BlurEmptyField("input-name"); // Verify error styling is present var clsBefore = await Input("input-name").GetAttributeAsync("class"); Assert.That(clsBefore, Does.Contain("border-destructive")); // Fix the field await FillAndBlur("input-name", "Alice"); var clsAfter = await Input("input-name").GetAttributeAsync("class"); Assert.That(clsAfter, Does.Not.Contain("border-destructive"), "Input should lose destructive border when valid"); } // ════════════════════════════════════════════════════════════════════════ // Full form submission validation // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Empty_Submit_Shows_All_Required_Errors() { await GoToFormsAsync(); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); // Name, email, and password should show errors await Expect(FieldError("name")).ToBeVisibleAsync(); await Expect(FieldError("email")).ToBeVisibleAsync(); await Expect(FieldError("password")).ToBeVisibleAsync(); } [Test] public async Task Valid_Submit_Shows_Success_Message() { await GoToFormsAsync(); await FillAllValidFields(); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(); await Expect(success).ToContainTextAsync("Form submitted successfully"); } [Test] public async Task Valid_Submit_Clears_All_Errors() { await GoToFormsAsync(); // First trigger errors await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); await Expect(FieldError("name")).ToBeVisibleAsync(); // Now fill valid data and resubmit await FillAllValidFields(); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); await Expect(FieldError("name")).ToBeHiddenAsync(); await Expect(FieldError("email")).ToBeHiddenAsync(); await Expect(FieldError("password")).ToBeHiddenAsync(); await Expect(FieldError("confirmation")).ToBeHiddenAsync(); } [Test] public async Task Partial_Submit_Shows_Only_Invalid_Errors() { await GoToFormsAsync(); // Fill name only await Input("input-name").FillAsync("Alice"); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); // Name should be clear, email and password should show errors await Expect(FieldError("name")).ToBeHiddenAsync(); await Expect(FieldError("email")).ToBeVisibleAsync(); await Expect(FieldError("password")).ToBeVisibleAsync(); } // ════════════════════════════════════════════════════════════════════════ // Reset clears validation state // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Reset_Clears_Validation_Errors() { await GoToFormsAsync(); // Trigger errors await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); await Expect(FieldError("name")).ToBeVisibleAsync(); // Reset the form await Btn("btn-reset").ClickAsync(); await Page.WaitForTimeoutAsync(300); await Expect(FieldError("name")).ToBeHiddenAsync(); await Expect(FieldError("email")).ToBeHiddenAsync(); await Expect(FieldError("password")).ToBeHiddenAsync(); } [Test] public async Task Reset_Clears_Success_Message() { await GoToFormsAsync(); // Submit valid form await FillAllValidFields(); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); await Expect(Page.Locator("[data-testid='success-message']")).ToBeVisibleAsync(); // Reset await Btn("btn-reset").ClickAsync(); await Page.WaitForTimeoutAsync(300); var result = Page.Locator("#form-result"); await Expect(result).ToBeHiddenAsync(); } [Test] public async Task Reset_Removes_Destructive_Border_Styling() { await GoToFormsAsync(); // Trigger error on name await BlurEmptyField("input-name"); var clsBefore = await Input("input-name").GetAttributeAsync("class"); Assert.That(clsBefore, Does.Contain("border-destructive")); // Reset await Btn("btn-reset").ClickAsync(); await Page.WaitForTimeoutAsync(300); var clsAfter = await Input("input-name").GetAttributeAsync("class"); Assert.That(clsAfter, Does.Not.Contain("border-destructive")); } // ════════════════════════════════════════════════════════════════════════ // Error elements exist for htmx targeting // ════════════════════════════════════════════════════════════════════════ [Test] public async Task FormField_Renders_Hidden_Error_Placeholder() { await GoToFormsAsync(); // Each form field should have a hidden [data-field-error] element var nameError = FieldError("name"); await Expect(nameError).ToHaveCountAsync(1); await Expect(nameError).ToBeHiddenAsync(); var emailError = FieldError("email"); await Expect(emailError).ToHaveCountAsync(1); await Expect(emailError).ToBeHiddenAsync(); } // ════════════════════════════════════════════════════════════════════════ // Custom validator — confirmation field must equal "CONFIRM" // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Confirmation_Blank_Shows_Required_Error_On_Blur() { await GoToFormsAsync(); await BlurEmptyField("input-confirmation"); var error = FieldError("confirmation"); await Expect(error).ToBeVisibleAsync(); await Expect(error).ToHaveTextAsync("Confirmation is required."); } [Test] public async Task Confirmation_Wrong_Value_Shows_Custom_Error_On_Blur() { await GoToFormsAsync(); await FillAndBlur("input-confirmation", "nope"); var error = FieldError("confirmation"); await Expect(error).ToBeVisibleAsync(); await Expect(error).ToHaveTextAsync("You must type CONFIRM to proceed."); } [Test] public async Task Confirmation_Correct_Value_Clears_Error_On_Blur() { await GoToFormsAsync(); // Trigger error first await FillAndBlur("input-confirmation", "wrong"); await Expect(FieldError("confirmation")).ToBeVisibleAsync(); // Now fix it await FillAndBlur("input-confirmation", "CONFIRM"); await Expect(FieldError("confirmation")).ToBeHiddenAsync(); } [Test] public async Task Confirmation_Error_Shows_On_Submit() { await GoToFormsAsync(); // Fill everything except confirmation await Input("input-name").FillAsync("Alice Johnson"); await Input("input-email").FillAsync("alice@example.com"); await Input("input-password").FillAsync("secure123"); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); await Expect(FieldError("confirmation")).ToBeVisibleAsync(); await Expect(FieldError("confirmation")).ToHaveTextAsync("Confirmation is required."); } // ════════════════════════════════════════════════════════════════════════ // Date/Time/DateTime validation // ════════════════════════════════════════════════════════════════════════ private async Task SetHiddenInputValue(string inputId, string value) { await Page.EvaluateAsync($@" const el = document.getElementById('{inputId}'); if (el) {{ el.value = '{value}'; el.dispatchEvent(new Event('change', {{ bubbles: true }})); }} "); } private ILocator PopoverPanelFor(string triggerId) => Page.Locator($"[data-testid='{triggerId}']") .Locator("xpath=ancestor::*[@data-popover]") .Locator("[data-popover-panel]"); private ILocator PopoverBackdropFor(string triggerId) => Page.Locator($"[data-testid='{triggerId}']") .Locator("xpath=ancestor::*[@data-popover]") .Locator("[data-popover-backdrop]"); private async Task SelectDateAsync(string triggerId, DateOnly target) { await Page.Locator($"[data-testid='{triggerId}']").ClickAsync(); await Page.WaitForTimeoutAsync(300); var panel = PopoverPanelFor(triggerId); var yearButton = panel.Locator("[data-calendar-year]"); await yearButton.ClickAsync(); await Page.WaitForTimeoutAsync(150); var yearGrid = panel.Locator(".grid.grid-cols-4"); var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')"); var attempts = 0; while (await targetYearBtn.CountAsync() == 0 && attempts < 10) { var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync(); var firstYear = int.Parse(firstYearText.Trim()); if (target.Year < firstYear) await panel.Locator("button[aria-label='Previous month']").ClickAsync(); else await panel.Locator("button[aria-label='Next month']").ClickAsync(); await Page.WaitForTimeoutAsync(100); yearGrid = panel.Locator(".grid.grid-cols-4"); targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')"); attempts++; } await targetYearBtn.First.ClickAsync(); await Page.WaitForTimeoutAsync(150); var monthButton = panel.Locator("[data-calendar-month]"); await monthButton.ClickAsync(); await Page.WaitForTimeoutAsync(150); var monthGrid = panel.Locator(".grid.grid-cols-3"); var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM"); await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync(); await Page.WaitForTimeoutAsync(150); var dayGrid = panel.Locator(".grid.grid-cols-7").Last; var dayButton = dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = target.Day.ToString() }).First; await dayButton.ClickAsync(); await Page.WaitForTimeoutAsync(300); } private async Task SelectTimeAsync(string triggerId, int hour, int minute) { await Page.Locator($"[data-testid='{triggerId}']").ClickAsync(); await Page.WaitForTimeoutAsync(300); var panel = PopoverPanelFor(triggerId); var isPm = hour >= 12; var hour12 = hour % 12; if (hour12 == 0) hour12 = 12; var hourColumn = panel.Locator(".scrollbar-thin").First; await hourColumn.Locator($"button:has-text('{hour12:D2}')").First.ClickAsync(); await Page.WaitForTimeoutAsync(50); var minuteColumn = panel.Locator(".scrollbar-thin").Nth(1); await minuteColumn.Locator($"button:has-text('{minute:D2}')").First.ClickAsync(); await Page.WaitForTimeoutAsync(50); var periodText = isPm ? "PM" : "AM"; await panel.Locator($"button:has-text('{periodText}')").First.ClickAsync(); await Page.WaitForTimeoutAsync(50); var backdrop = PopoverBackdropFor(triggerId); await backdrop.ClickAsync(new LocatorClickOptions { Force = true }); await Page.WaitForTimeoutAsync(200); } [Test] public async Task BirthDate_Selected_Via_Popover_Submits_Successfully() { await GoToFormsAsync(); await FillAllValidFields(); await SelectDateAsync("trigger-birthdate", new DateOnly(2000, 6, 15)); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(); await Expect(FieldError("birthdate")).ToBeHiddenAsync(); } [Test] public async Task PreferredTime_Selected_Via_Popover_Submits_Successfully() { await GoToFormsAsync(); await FillAllValidFields(); await SelectTimeAsync("trigger-preferredtime", 14, 30); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(); await Expect(FieldError("preferredtime")).ToBeHiddenAsync(); } [Test] public async Task Appointment_Date_And_Time_Selected_Via_Popover_Submits_Successfully() { await GoToFormsAsync(); await FillAllValidFields(); await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25)); await SelectTimeAsync("trigger-appointment-time", 10, 30); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(); await Expect(FieldError("appointment")).ToBeHiddenAsync(); } [Test] public async Task BirthDate_Hidden_Input_Gets_Correct_Value_After_Selection() { await GoToFormsAsync(); await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 20)); var value = await Page.Locator("#birthdate").InputValueAsync(); Assert.That(value, Is.EqualTo("1995-03-20")); } [Test] public async Task PreferredTime_Hidden_Input_Gets_Correct_Value_After_Selection() { await GoToFormsAsync(); await SelectTimeAsync("trigger-preferredtime", 9, 15); var value = await Page.Locator("#preferredtime").InputValueAsync(); Assert.That(value, Is.EqualTo("09:15")); } [Test] public async Task Submit_With_Valid_Date_Time_DateTime_Via_Hidden_Input_Succeeds() { await GoToFormsAsync(); await FillAllValidFields(); await SetHiddenInputValue("birthdate", "2000-06-15"); await SetHiddenInputValue("preferredtime", "14:30"); await SetHiddenInputValue("appointment", "2025-12-25T10:30"); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(); await Expect(success).ToContainTextAsync("Form submitted successfully"); } [Test] public async Task Submit_With_Empty_Optional_DateTime_Fields_Succeeds() { await GoToFormsAsync(); await FillAllValidFields(); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(); await Expect(FieldError("birthdate")).ToBeHiddenAsync(); await Expect(FieldError("preferredtime")).ToBeHiddenAsync(); await Expect(FieldError("appointment")).ToBeHiddenAsync(); } [Test] public async Task Submit_All_Fields_Including_DateTime_Shows_Success() { await GoToFormsAsync(); await FillAllValidFields(); await SelectDateAsync("trigger-birthdate", new DateOnly(1990, 1, 15)); await SelectTimeAsync("trigger-preferredtime", 16, 0); await SelectDateAsync("trigger-appointment-date", new DateOnly(2026, 6, 1)); await SelectTimeAsync("trigger-appointment-time", 9, 0); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(); await Expect(success).ToContainTextAsync("Form submitted successfully"); } [Test] public async Task Reset_Clears_DateTime_Error_Placeholders() { await GoToFormsAsync(); await Btn("btn-submit").ClickAsync(); await Page.WaitForTimeoutAsync(500); await Btn("btn-reset").ClickAsync(); await Page.WaitForTimeoutAsync(300); await Expect(FieldError("birthdate")).ToBeHiddenAsync(); await Expect(FieldError("preferredtime")).ToBeHiddenAsync(); await Expect(FieldError("appointment")).ToBeHiddenAsync(); } }