d1f0967a0c
- Replace server interactivity with vanilla JS (forms.js) for Popover, Calendar, TimePicker, NumberInput, and Counter components - Rewrite all Razor components to static SSR using data-* attributes for JS hooks - Simplify InputBase.cs (remove EventCallback, EditContext, SetValueAsync) - Remove AddInteractiveServerComponents/AddInteractiveServerRenderMode from Program.cs - Update demo pages: remove @rendermode, replace EditForm with native form - Add InteractivityGapTests.cs with 30 scoped E2E tests - Update FormsTests.cs selectors for new static SSR structure - Fix year picker navigation bug and date format mismatch in forms.js - All 126 tests passing
458 lines
19 KiB
C#
458 lines
19 KiB
C#
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}']");
|
|
|
|
/// <summary>
|
|
/// Get the open popover panel nearest to a trigger.
|
|
/// </summary>
|
|
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]");
|
|
|
|
/// <summary>
|
|
/// Select a date via the calendar popover.
|
|
/// Opens the trigger, uses month/year pickers to navigate, then clicks the day.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Navigate the open calendar to a specific month/year using the month and year pickers.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Select a time via the time picker popover.
|
|
/// Opens the trigger, clicks the hour, minute, and AM/PM.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pick hour, minute, and AM/PM in an already-open time picker.
|
|
/// </summary>
|
|
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(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");
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
// 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");
|
|
}
|
|
}
|