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 the form to be rendered
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}']");
///
/// Get the open popover panel nearest to a trigger.
///
private ILocator PopoverPanelFor(string triggerId) =>
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-panel]");
private ILocator PopoverBackdropFor(string triggerId) =>
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-backdrop]");
///
/// 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(300);
var panel = PopoverPanelFor(triggerId);
await NavigateCalendarToDate(panel, target);
// Click the target day
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);
}
///
/// Navigate the open calendar to a specific month/year using the month and year pickers.
///
private async Task NavigateCalendarToDate(ILocator panel, DateOnly target)
{
// Click year header to open year picker, then select the year
var yearButton = panel.Locator("[data-calendar-year]");
await yearButton.ClickAsync();
await Page.WaitForTimeoutAsync(150);
// The year picker grid is inside the calendar content
var yearGrid = panel.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
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);
// Now click month header to open month picker, then select the month
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);
}
///
/// 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(300);
var panel = PopoverPanelFor(triggerId);
await PickTimeInOpenPopover(panel, hour, minute);
// Close popover by clicking the backdrop overlay
var backdrop = PopoverBackdropFor(triggerId);
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(200);
}
///
/// Pick hour, minute, and AM/PM in an already-open time picker.
///
private async Task PickTimeInOpenPopover(ILocator panel, int hour, int minute)
{
// 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
var hourText = hour12.ToString("D2");
var hourColumn = panel.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
var minuteText = minute.ToString("D2");
var minuteColumn = panel.Locator(".scrollbar-thin").Nth(1);
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
// Click AM/PM
var periodText = isPm ? "PM" : "AM";
await panel.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(8), "Expected 8 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");
}
// ════════════════════════════════════════════════════════════════════════
// Value binding (native)
// ════════════════════════════════════════════════════════════════════════
[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 datetime-local input should have the combined value
// Note: DateTime hidden input is composed from separate date/time part hidden inputs
var datePartVal = await Page.Locator("#appointment-date-part").InputValueAsync();
var timePartVal = await Page.Locator("#appointment-time-part").InputValueAsync();
Assert.That(datePartVal, Is.EqualTo("2025-12-25"));
Assert.That(timePartVal, Is.EqualTo("10:00"));
}
// ════════════════════════════════════════════════════════════════════════
// 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 (native HTML 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 (native form reset)
await Btn("btn-reset").ClickAsync();
// Fields should be empty
await Expect(Input("input-name")).ToHaveValueAsync("");
await Expect(Input("input-email")).ToHaveValueAsync("");
}
// ════════════════════════════════════════════════════════════════════════
// 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");
}
}