using Microsoft.Playwright; namespace Enciphered.Blazor.UIComponents.Tests; [TestFixture] public class FormsTests : PlaywrightTestBase { // ── Helpers ────────────────────────────────────────────────────────────── private async Task GoToFormsAsync() { await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); // Wait for Blazor interactive mode to be ready await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 }); } private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']"); private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']"); private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']"); /// /// Select a date via the calendar popover. /// Opens the trigger, uses month/year pickers to navigate, then clicks the day. /// private async Task SelectDateAsync(string triggerId, DateOnly target) { // Open the popover await Trigger(triggerId).ClickAsync(); await Page.WaitForTimeoutAsync(200); await NavigateCalendarToDate(target); // Click the target day (only enabled buttons in the calendar day grid) // The day grid is the last grid-cols-7 div; find the button with matching day text var dayGrid = Page.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(200); } /// /// Navigate the open calendar to a specific month/year using the month and year pickers. /// private async Task NavigateCalendarToDate(DateOnly target) { // Click year header to open year picker, then select the year var yearButton = Page.Locator("[data-calendar-year]"); await yearButton.ClickAsync(); await Page.WaitForTimeoutAsync(100); // The year picker is a scrollable grid; find and click the target year var yearGrid = Page.Locator(".grid.grid-cols-4"); var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')"); // If the year isn't visible, use prev/next to shift the year range (±20 per click) var attempts = 0; while (await targetYearBtn.CountAsync() == 0 && attempts < 10) { // Read the first year button text to determine which direction to go var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync(); var firstYear = int.Parse(firstYearText.Trim()); if (target.Year < firstYear) await Page.Locator("button[aria-label='Previous month']").ClickAsync(); else await Page.Locator("button[aria-label='Next month']").ClickAsync(); await Page.WaitForTimeoutAsync(50); yearGrid = Page.Locator(".grid.grid-cols-4"); targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')"); attempts++; } await targetYearBtn.First.ClickAsync(); await Page.WaitForTimeoutAsync(100); // Now click month header to open month picker, then select the month var monthButton = Page.Locator("[data-calendar-month]"); await monthButton.ClickAsync(); await Page.WaitForTimeoutAsync(100); var monthGrid = Page.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(100); } /// /// Select a time via the time picker popover. /// Opens the trigger, clicks the hour, minute, and AM/PM. /// private async Task SelectTimeAsync(string triggerId, int hour, int minute) { // Open the popover await Trigger(triggerId).ClickAsync(); await Page.WaitForTimeoutAsync(200); await PickTimeInOpenPopover(hour, minute); // Close popover by clicking the backdrop overlay await Page.Locator(".fixed.inset-0.z-40").ClickAsync(new LocatorClickOptions { Force = true }); await Page.WaitForTimeoutAsync(100); } /// /// Pick hour, minute, and AM/PM in an already-open time picker. /// Scopes all locators to the visible popover content to avoid backdrop interception. /// private async Task PickTimeInOpenPopover(int hour, int minute) { // The popover content sits in a z-50 absolutely positioned container var popoverContent = Page.Locator(".absolute.z-50"); // Convert to 12-hour format var isPm = hour >= 12; var hour12 = hour % 12; if (hour12 == 0) hour12 = 12; // Click the hour in the first scrollable column (within the popover) var hourText = hour12.ToString("D2"); var hourColumn = popoverContent.Locator(".scrollbar-thin").First; await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync(); await Page.WaitForTimeoutAsync(50); // Click the minute in the second scrollable column (within the popover) var minuteText = minute.ToString("D2"); var minuteColumn = popoverContent.Locator(".scrollbar-thin").Nth(1); await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync(); await Page.WaitForTimeoutAsync(50); // Click AM/PM (within the popover) var periodText = isPm ? "PM" : "AM"; await popoverContent.Locator($"button:has-text('{periodText}')").First.ClickAsync(); await Page.WaitForTimeoutAsync(50); } // ════════════════════════════════════════════════════════════════════════ // Rendering // ════════════════════════════════════════════════════════════════════════ [Test] public async Task FormsPage_Loads_Successfully() { var response = await Page.GotoAsync($"{BaseUrl}/forms"); Assert.That(response!.Status, Is.EqualTo(200)); } [Test] public async Task FormsPage_Has_Title() { await GoToFormsAsync(); var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl"); await Expect(heading).ToHaveTextAsync("Forms Demo"); } [Test] public async Task All_Inputs_Are_Rendered() { await GoToFormsAsync(); await Expect(Input("input-name")).ToBeVisibleAsync(); await Expect(Input("input-email")).ToBeVisibleAsync(); await Expect(Input("input-password")).ToBeVisibleAsync(); await Expect(Input("input-age")).ToBeVisibleAsync(); // Date/Time/DateTime use popover triggers instead of visible native inputs await Expect(Trigger("trigger-birthdate")).ToBeVisibleAsync(); await Expect(Trigger("trigger-preferredtime")).ToBeVisibleAsync(); await Expect(Trigger("trigger-appointment-date")).ToBeVisibleAsync(); await Expect(Trigger("trigger-appointment-time")).ToBeVisibleAsync(); } [Test] public async Task All_Buttons_Are_Rendered() { await GoToFormsAsync(); await Expect(Btn("btn-submit")).ToBeVisibleAsync(); await Expect(Btn("btn-reset")).ToBeVisibleAsync(); await Expect(Btn("btn-disabled")).ToBeVisibleAsync(); } // ════════════════════════════════════════════════════════════════════════ // Input types // ════════════════════════════════════════════════════════════════════════ [Test] public async Task TextInput_Has_Correct_Type() { await GoToFormsAsync(); await Expect(Input("input-name")).ToHaveAttributeAsync("type", "text"); } [Test] public async Task EmailInput_Has_Correct_Type() { await GoToFormsAsync(); await Expect(Input("input-email")).ToHaveAttributeAsync("type", "email"); } [Test] public async Task PasswordInput_Has_Correct_Type() { await GoToFormsAsync(); await Expect(Input("input-password")).ToHaveAttributeAsync("type", "password"); } [Test] public async Task NumberInput_Has_Correct_Type() { await GoToFormsAsync(); await Expect(Input("input-age")).ToHaveAttributeAsync("type", "number"); } [Test] public async Task DateInput_Has_Correct_Type() { await GoToFormsAsync(); await Expect(Input("input-birthdate")).ToHaveAttributeAsync("type", "date"); } [Test] public async Task TimeInput_Has_Correct_Type() { await GoToFormsAsync(); await Expect(Input("input-time")).ToHaveAttributeAsync("type", "time"); } [Test] public async Task DateTimeInput_Has_Correct_Type() { await GoToFormsAsync(); await Expect(Input("input-appointment")).ToHaveAttributeAsync("type", "datetime-local"); } // ════════════════════════════════════════════════════════════════════════ // Placeholders // ════════════════════════════════════════════════════════════════════════ [Test] public async Task TextInput_Shows_Placeholder() { await GoToFormsAsync(); await Expect(Input("input-name")).ToHaveAttributeAsync("placeholder", "Jane Doe"); } [Test] public async Task EmailInput_Shows_Placeholder() { await GoToFormsAsync(); await Expect(Input("input-email")).ToHaveAttributeAsync("placeholder", "jane@example.com"); } // ════════════════════════════════════════════════════════════════════════ // Labels // ════════════════════════════════════════════════════════════════════════ [Test] public async Task FormFields_Have_Labels() { await GoToFormsAsync(); var labels = Page.Locator("label"); var count = await labels.CountAsync(); Assert.That(count, Is.EqualTo(7), "Expected 7 labels (one per form field)"); } [Test] public async Task Label_For_Attribute_Matches_Input_Id() { await GoToFormsAsync(); var label = Page.Locator("label[for='name']"); await Expect(label).ToHaveTextAsync("Full Name"); var input = Input("input-name"); await Expect(input).ToHaveAttributeAsync("id", "name"); } // ════════════════════════════════════════════════════════════════════════ // Two-way binding // ════════════════════════════════════════════════════════════════════════ [Test] public async Task TextInput_Binds_Value() { await GoToFormsAsync(); var input = Input("input-name"); await input.FillAsync("Alice"); await Expect(input).ToHaveValueAsync("Alice"); } [Test] public async Task NumberInput_Binds_Value() { await GoToFormsAsync(); var input = Input("input-age"); await input.FillAsync("30"); await Expect(input).ToHaveValueAsync("30"); } [Test] public async Task DateInput_Binds_Value() { await GoToFormsAsync(); // Use the calendar popover to select June 15, 2000 await SelectDateAsync("trigger-birthdate", new DateOnly(2000, 6, 15)); // The hidden input should reflect the selected date await Expect(Input("input-birthdate")).ToHaveValueAsync("2000-06-15"); } [Test] public async Task TimeInput_Binds_Value() { await GoToFormsAsync(); // Use the time picker popover to select 14:30 (2:30 PM) await SelectTimeAsync("trigger-preferredtime", 14, 30); // The hidden input should reflect the selected time await Expect(Input("input-time")).ToHaveValueAsync("14:30"); } [Test] public async Task DateTimeInput_Binds_Value() { await GoToFormsAsync(); // Pick the date part via the date trigger await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25)); // Pick the time part via the time trigger await SelectTimeAsync("trigger-appointment-time", 10, 0); // The hidden input should have the combined datetime value var value = await Input("input-appointment").InputValueAsync(); Assert.That(value, Does.StartWith("2025-12-25T10:00")); } // ════════════════════════════════════════════════════════════════════════ // Validation — empty submit shows errors // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Empty_Submit_Shows_Validation_Errors() { await GoToFormsAsync(); await Btn("btn-submit").ClickAsync(); // Wait for at least one error message to appear await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 }); var errors = Page.Locator("p.text-destructive"); var count = await errors.CountAsync(); Assert.That(count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 validation errors (one per required field)"); } [Test] public async Task Validation_Error_Shows_Name_Required() { await GoToFormsAsync(); await Btn("btn-submit").ClickAsync(); await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 }); var nameError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Name is required" }); await Expect(nameError).ToBeVisibleAsync(); } [Test] public async Task Validation_Error_Shows_Email_Required() { await GoToFormsAsync(); await Btn("btn-submit").ClickAsync(); await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 }); var emailError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Email is required" }); await Expect(emailError).ToBeVisibleAsync(); } // ════════════════════════════════════════════════════════════════════════ // Validation — specific error messages // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Short_Name_Shows_Length_Error() { await GoToFormsAsync(); var input = Input("input-name"); await input.FillAsync("A"); await Btn("btn-submit").ClickAsync(); await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 }); var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "2–100 characters" }); await Expect(error).ToBeVisibleAsync(); } [Test] public async Task Invalid_Email_Shows_Error() { await GoToFormsAsync(); var input = Input("input-email"); await input.FillAsync("not-an-email"); await Btn("btn-submit").ClickAsync(); await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 }); var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Invalid email" }); await Expect(error).ToBeVisibleAsync(); } [Test] public async Task Short_Password_Shows_Error() { await GoToFormsAsync(); var input = Input("input-password"); await input.FillAsync("123"); await Btn("btn-submit").ClickAsync(); await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 }); var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "8–64 characters" }); await Expect(error).ToBeVisibleAsync(); } // ════════════════════════════════════════════════════════════════════════ // Valid submission // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Valid_Form_Shows_Success_Message() { await GoToFormsAsync(); await Input("input-name").FillAsync("Jane Doe"); await Input("input-email").FillAsync("jane@example.com"); await Input("input-password").FillAsync("securepassword123"); await Input("input-age").FillAsync("30"); // Use popover pickers for date/time fields await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15)); await SelectTimeAsync("trigger-preferredtime", 9, 30); // DateTime: pick date and time via separate triggers await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25)); await SelectTimeAsync("trigger-appointment-time", 10, 0); await Btn("btn-submit").ClickAsync(); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 }); await Expect(success).ToContainTextAsync("Form submitted successfully"); await Expect(success).ToContainTextAsync("Jane Doe"); } [Test] public async Task No_Success_Message_Before_Submit() { await GoToFormsAsync(); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeHiddenAsync(); } // ════════════════════════════════════════════════════════════════════════ // Button variants // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Submit_Button_Has_Default_Variant_Classes() { await GoToFormsAsync(); var btn = Btn("btn-submit"); var cls = await btn.GetAttributeAsync("class"); Assert.That(cls, Does.Contain("bg-primary"), "Submit should use default variant"); } [Test] public async Task Reset_Button_Has_Outline_Variant_Classes() { await GoToFormsAsync(); var btn = Btn("btn-reset"); var cls = await btn.GetAttributeAsync("class"); Assert.That(cls, Does.Contain("border"), "Reset should use outline variant"); Assert.That(cls, Does.Contain("bg-background"), "Reset should use outline variant"); } [Test] public async Task Disabled_Button_Is_Actually_Disabled() { await GoToFormsAsync(); var btn = Btn("btn-disabled"); await Expect(btn).ToBeDisabledAsync(); } [Test] public async Task Disabled_Button_Has_Destructive_Variant() { await GoToFormsAsync(); var btn = Btn("btn-disabled"); var cls = await btn.GetAttributeAsync("class"); Assert.That(cls, Does.Contain("bg-destructive"), "Disabled button should have destructive variant"); } // ════════════════════════════════════════════════════════════════════════ // Reset // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Reset_Button_Clears_Form() { await GoToFormsAsync(); // Fill some fields await Input("input-name").FillAsync("Alice"); await Input("input-email").FillAsync("alice@test.com"); // Reset await Btn("btn-reset").ClickAsync(); // Fields should be empty await Expect(Input("input-name")).ToHaveValueAsync(""); await Expect(Input("input-email")).ToHaveValueAsync(""); } [Test] public async Task Reset_Button_Clears_Success_Message() { await GoToFormsAsync(); // Submit valid form await Input("input-name").FillAsync("Jane Doe"); await Input("input-email").FillAsync("jane@example.com"); await Input("input-password").FillAsync("securepassword123"); await Input("input-age").FillAsync("30"); await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15)); await SelectTimeAsync("trigger-preferredtime", 9, 30); // DateTime picker — date and time via separate triggers await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25)); await SelectTimeAsync("trigger-appointment-time", 10, 0); await Btn("btn-submit").ClickAsync(); var success = Page.Locator("[data-testid='success-message']"); await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 }); // Reset await Btn("btn-reset").ClickAsync(); await Expect(success).ToBeHiddenAsync(); } // ════════════════════════════════════════════════════════════════════════ // Input styling (base CSS classes present) // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Inputs_Have_Base_Styling_Classes() { await GoToFormsAsync(); var input = Input("input-name"); var cls = await input.GetAttributeAsync("class"); Assert.That(cls, Does.Contain("rounded-md"), "Input should have rounded-md class"); Assert.That(cls, Does.Contain("border"), "Input should have border class"); } // ════════════════════════════════════════════════════════════════════════ // Navigation to forms page // ════════════════════════════════════════════════════════════════════════ [Test] public async Task Sidebar_Has_Forms_Link() { await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 }); var formsLink = Page.Locator("a[href='/forms']"); await Expect(formsLink).ToBeVisibleAsync(); } [Test] public async Task Navigate_To_Forms_Via_Sidebar() { await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 }); await Page.Locator("a[href='/forms']").ClickAsync(); await Page.WaitForURLAsync($"{BaseUrl}/forms"); var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl"); await Expect(heading).ToHaveTextAsync("Forms Demo"); } }